Architecture
akm is a Bun-based CLI for discovering and using agent assets from local filesystem sources, cache-backed sources (git, website, npm), and registry catalogs.
This document is the operating summary of the current architecture and is the current-truth reference.
Asset Types
The akm adapter's fourteen built-in asset types (skill, command,
agent, knowledge, instruction, workflow, script, memory,
lesson, fact, env, secret, task, session) each map to a
canonical source directory through src/core/asset/asset-placement.ts's
PLACEMENT_SPECS map. See
Bundle Types for the full table.
The deprecated vault type was removed in 0.9.0 and replaced by env (whole
.env files) and secret (single-value secret files). wiki is not an item
type: multi-page wikis are a bundle format owned by the llm-wiki adapter,
not a per-file type stamped by the classifier (see
Classification and
Bundle Types for the other ten formats akm
recognizes besides its own native one).
Sources and Source Providers
A source is a directory plus a way to refresh it from upstream. There are exactly four source provider types:
filesystem— a local path the user ownsgit— a git working tree mirrored under akm's cachewebsite— recrawled and converted to markdownnpm— installed into the cache
All four kinds expose the same minimal SourceProvider interface
(src/sources/provider.ts):
interface SourceProvider {
readonly name: string;
readonly kind: string; // "filesystem" | "git" | "website" | "npm"
path(): string; // directory the indexer walks
sync?(options?: { force?: boolean }): Promise<void>; // refresh from upstream (no-op for filesystem)
}
Providers do not implement search, show, canShow, or any read method.
The indexer walks path(), classifies files, and answers all queries from the
local FTS5 index.
File semantics are owned by the selected bundle adapter. OKF is the first-class
least-common-denominator for Markdown path identity, open types, content, links,
and heading fragments. The akm format is an OKF-compatible Markdown superset:
its adapter progressively adds native command, script, workflow, task,
environment, secret, memory, and lesson behavior. Non-Markdown formats retain
their native serialization and every adapter retains its own capability,
validation, redaction, and placement rules.
The legacy LiveStashProvider / SyncableStashProvider split is gone, as is
any "remote-only" provider tier. API-backed sources (mem0, Notion, etc.) are
deferred to a separate QuerySource tier post-v1.
Cache-backed sources are still indexed locally
git, website, and npm all materialise files into a cache directory under
$XDG_CACHE_HOME/akm/. Once mirrored, they participate in the same local
indexing pipeline as filesystem sources. There is no parallel scoring system
for "remote" content.
Refs
User-facing item refs are path-identified:
[bundle//]conceptId[#fragment]
- the subdir-qualified
conceptId(skills/code-review) identifies an item within a bundle;typeis no longer a separate ref segment - the fully qualified
bundle//conceptIdspelling is canonical in index rows and durable state; index-backed local read output keeps the short form for the default bundle and qualifies every non-default bundle - optional
bundle//narrows input lookup to a configured bundle - refs are parsed by
parseBundleRefinsrc/core/asset/asset-ref.ts - markdown-backed items strip
.mdfrom canonical names #fragmentis input-only and never stored: on markdown-document items the core resolves it as a section selector (knowledge/api-guide#authentication); elsewhere it is an adapter-owned selector opaque to the core- refs embedded in prose must be fully qualified (
bundle//conceptId) or a native adapter link form — a bare conceptId in prose is ordinary text, and no tool rewrites it - a rename is delete plus create: the new path is a new identity and learned state does not follow it
See specs/ref.md for the normative grammar and
specs/0.9.0-decisions.md for the rationale
behind the 0.9.0 changes.
Each indexed entry stores that identity as item_ref, with bundle_id and
concept_id provenance, and stores its absolute materialized local file as
file_path. Search and show return these index-backed refs and paths, so two
bundles containing the same concept remain distinguishable. Rows without the
current identity columns are ignored until they are reindexed.
Examples:
skills/code-reviewworkflows/release/trainteam//commands/deploy
URI schemes (viking://..., github://...) are not asset refs. Install
locators like github:owner/repo, git+https://..., npm:@scope/pkg,
skills.sh:slug, and ./local/path are a separate grammar parsed by
parseRegistryRef in src/registry/resolve.ts and consumed by
akm add / akm clone.
Search Pipeline
There is one ranking for all indexed content: reciprocal rank fusion (k = 60, equal weights) of two candidate lists, 100 each.
- lexical: BM25 over the whole-document FTS5 table (
entries_fts, columnsname,description,tags,hints,content, equal weights), matching any of the query's non-stopword tokens - vector: the document vectors nearest to the query embedding, embedded
through the model's embedding profile (
src/llm/embedders/profile.ts)
Filters (type, source, scope, belief, proposed quality, default-excluded
types) and one-hit-per-file deduplication narrow the fused list without
reordering it, and a hit's score is its fused score. Of entries with
identical indexed content only the highest-ranked is kept. Nothing else — name,
type, tag, graph, usage or belief-state signals — changes the order. The
design and its measured alternatives are in the retrieval evaluation
(akm/eval/retrieval/reports/baseline-2026-09-27.md).
Notes:
- lexical queries are tokenized once with Unicode letter/number semantics, lowercased, and stripped of English stopwords unless the query is nothing else; the tokens are quoted and OR-ed
- the query embedding is requested before the FTS query runs and is bounded
by
embedding.queryTimeoutMs(default 3000); on timeout or failure the search is served by the lexical list alone - Markdown fragments are not searched;
akm showreads the stored safe Markdown (entry_fragments) for#fragmentrefs hintsincludessearchHints,examples,usage, intent fields, wiki cross-references, and page-kind hintscontentis bounded low-weight body prose plus TOC headings and parameter metadata; secret/env/session material is excluded at the adapter boundary- registry results live in
registryHits, never inhits --from allkeeps registry results inregistryHits— they are not rank-merged with source hits- local search and curate expose the actual execution in
searchMode:semantic, intentional/unavailablekeyword, orfts-fallbackwhen a ready semantic runtime failed during the query; that degradation also surfaces as one sanitizedwarnings[]entry, including in agent shape - usage-event writes are best-effort;
EROFS/EACCESfrom a read-only environment are silent and never affectsearchMode
akm search is implemented in src/commands/read/search.ts and queries the
indexer's local search (src/indexer/search/db-search.ts). Provider fan-out is gone.
Show Resolution
akm show queries the local FTS5 index, then reads the file from disk.
Local show flow (src/commands/read/show.ts):
- parse
[bundle//]conceptId lookup(ref)by indexeditem_refand materialized source root- return the indexed canonical ref and read
file_pathfrom disk - report not-found when no index row resolves; physical ownership can prevent a lower-priority source from retargeting a ref, but never authorizes a direct file read
- apply generic Markdown content/fragment presentation for OKF, or the owning adapter's progressively enhanced native presentation
An opaque #akm-fragment-... selector resolves against the safe Markdown
revision persisted in entry_fragments, so search and show remain consistent
if the materialized file changes before it is reindexed. Bare fragment show is
still exact. The opt-in --context lead projection assembles the first and
selected fragments from that same indexed revision under a hard character
budget, labels the selected match, and keeps it last. It never reconstructs
neighbors from the current raw file. Fragment search plus opaque/contextual show
JSON exposes the selected and parent refs, one-based ordinal/count and
source-line bounds, neighbor refs, and separate fragment/parent size estimates;
canonical ref remains the parent on show. A default exact friendly-heading
show stays source-live and omits indexed provenance.
There is no provider or local-disk fallback for asset refs. The file is read only after an indexed row resolves it.
Local access metadata
Agent-shaped search, show, and curate results for materialized local assets
include the index-backed ref, absolute path, and editable. editHint is
included only when editable is false; it is secondary guidance and never
replaces the normal show, run, or use action (or curate follow-up). Registry-only
results have no local path or editability fields.
editable means current AKM source policy authorizes direct in-place
modification of that exact file. It is computed when the response is built from
the current resolved source roots and effective writable policy, is not stored
in the index, and fails closed when no configured source owns the path.
Writing to Sources
Writes go through one helper: src/core/write-source.ts. This is the only
place in the codebase that branches on source.kind, and that is enforced
by scripts/lint-write-source-chokepoint.ts in the lint chain — it was
prose-only until 0.9.0, and drifted.
Command layers therefore never hold provider knowledge. Where a step is
meaningful only for a publication-backed target, write-source.ts exposes a
kind-neutral wrapper that absorbs the guard and no-ops otherwise:
prepareWriteTargetForMutation, commitWriteTargetBoundary, and
withWriteTargetMutation (write the file, git add exactly the recorded
paths, commit, push with --force-with-lease when configured). Recording a
kind for display (targetKind: target.source.kind) is not branching and stays
in the command layer.
writeAssetToSource(source, config, ref, content)
deleteAssetFromSource(source, config, ref)
The flow:
- Refuse if the source is not
writable. - Plain filesystem write to
path.join(source.path, …)— for every kind, with no commit (0.9.0, issue #507).
Git-backed targets are committed in a single batch at the operation boundary via
commitWriteTargetBoundary(target, message, { push }), which delegates to
saveGitStash: write/delete helpers carry their exact changed paths to the
boundary, which stages and commits only those files (so unrelated staged work,
including work under the same asset directory, is not included). Improve
auto-sync similarly subtracts the Git dirty-path baseline captured at invocation
start. Push remains gated on writable && hasRemote && push !== false. The old
per-asset commit/push path (options.pushOnCommit) is hard-rejected at
config load — not merely deprecated — by a superRefine on the bundle/source
schema (src/core/config/schema/sources-bundles.ts).
writable is a config flag, not an interface concern. Defaults: true for
filesystem, false for everything else. writable: true on website or
npm is rejected at config load — sync() would clobber edits on the next
refresh.
Write-target resolution (resolveWriteTarget) follows: an explicit
destination flag (--bundle on remember/clone/improve, --target on
import/env/secret mutations) -> config.defaultWriteTarget -> working
bundle (defaultBundle) -> ConfigError. The resolved target keeps the
optional configured selector separate from the stable source.name: APIs
that must re-resolve a destination use the selector, while durable refs and
state rows always use source.name. The implicit working bundle therefore has
no selector but has durable identity stash.
On mutation surfaces that accept asset refs, a qualified ref implies its
bundle as the write target. A matching explicit destination flag is allowed;
a different one is a usage error. This applies, for example, to env/secret
mutations, ref-scoped improve, and qualified --supersedes refs on
remember/import.
akm clone uses the same managed write-target fallback and accepts
--bundle for an explicit managed destination. --dest is the unmanaged
path escape hatch: it bypasses managed target resolution and cannot be
combined with --bundle.
Improve durable-state identity
Improve readers and writers use one current key for each asset. A resolved
indexed entry uses its fully qualified item_ref; a direct or provenance-free
ref uses its current concept ID. There is no alternate root-based key, bare alias
merge, or second state lookup. This prevents a duplicate concept in another
installation from inheriting unrelated feedback, proposal-cursor, salience, or
convergence state.
Retrieval demand is scoped separately through usage-event entry IDs and selected source roots, with qualified refs covering detached events. Improve never merges retrieval counts or last-use timestamps solely by bare ref across sources.
Proposal queues and destinations
akm proposal ... --queue <source> selects the configured writable source root
whose proposal queue is read or adjudicated; it does not override the proposal's
write destination. Qualified proposals and unqualified proposals created in a
configured secondary queue record the destination source name and materialized
root. Diff, accept, and revert use that binding by default and reject an explicit
--target that resolves elsewhere. An unbound short proposal requires either
an explicit --target or an authenticated --queue context; it does not
inherit a default write target.
Registry Providers
Registry providers are read-only catalogs of installable kits. The interface
lives in src/registry/providers/types.ts:
interface RegistryProvider {
readonly type: string; // "static-index" | "skills-sh"
search(options: RegistryProviderSearchOptions): Promise<RegistryProviderResult>;
}
The contract is a single search() method — the orchestrator's only entry
point. Implementations must never throw; errors are returned as
warnings[] on the result.
Built-in registries:
| Kind | Role |
|---|---|
static-index |
Reads the v2 or v3 JSON index schema, same stashes[] wire format (official akm registry, team mirrors). scripts/build-registry-index.ts emits v3; the live official registry currently publishes v2. |
skills-sh |
Wraps the skills.sh REST API. |
Context Hub is not a registry provider type. It is just a recommended git kit installable through the official static-index registry like any other source.
Workflow Runtime State
Workflow definitions live in workflows/, but workflow run state is separate
durable runtime state. workflow_runs, workflow_run_steps,
workflow_run_units, and workflow_run_unit_attempts live in state.db
alongside events, tasks, and proposals. index.db remains rebuildable search
state, while logs.db stores task/run log lines.
- workflow discovery and search use the shared asset index
- workflow run records survive index rebuilds
- workflow run state is not derived from the FTS index
Utility Scoring
Utility is feedback-driven and rebuilt from usage_events. It orders
improve's salience work; search ranking does not read it.
- usage history is preserved across schema resets and full rebuilds
- detached events are re-linked to fresh entry ids by ref
- decay is time-proportional, not tied to index frequency
Errors
ConfigError, UsageError, and NotFoundError (in src/core/errors.ts) each
carry a stable code and a hint(): string | undefined method. The CLI
surfaces hints by calling error.hint() directly — there is no regex chain
parsing error messages.
All three extend a shared abstract base AkmError carrying a kind
discriminant ("config" | "usage" | "not-found"). The CLI exit-code classifier
(classifyExitCode in src/cli/shared.ts) switches exhaustively on kind
(never-checked via assertNever), so adding a new error class is a
compile-time error until its exit code is mapped. Any thrown value that is
not an AkmError is treated as an unexpected internal failure and maps to
the distinct INTERNAL exit code 70 (sysexits EX_SOFTWARE) — this lets
scripts tell "akm threw unexpectedly" apart from an ordinary NotFoundError
(exit 1).
| Class / case | Exit code |
|---|---|
ConfigError (kind: "config") |
78 |
UsageError (kind: "usage") |
2 |
NotFoundError (kind: "not-found") |
1 |
unclassified / non-AkmError (INTERNAL) |
70 |
Engine Boundary
The approved target semantics that connect native agent and command assets to this engine boundary are specified in Agent, Command, Engine, and Model Resolution. The WP2/WP3 model-map and common-cascade path, WP4 command surface, WP5 runtime lowering convergence, WP6 task v3, and WP7 source-to-frozen durable workflow IR are implemented. Direct commands, task execution, and new workflow freezes share this boundary.
Public execution selection uses named engines, never profiles. An engine is
either kind: "llm" (an OpenAI-compatible chat-completions connection) or
kind: "agent" (a registered harness platform). The SDK runtime is an
internal transport kind for an opencode-sdk agent engine, not a public engine
kind.
Every execution — direct commands, tasks, workflows, improve, proposals, and
index passes — takes the same four steps
(src/integrations/agent/execution.ts, runner-dispatch.ts):
adapter-rendered or anonymous work
-> resolveExecution(input) -> { request, runner (a RunnerSpec), provenance }
-> buildExecution(request, runner) -> harness argv inputs or chat messages
-> runExecution(built, options) -> agent CLI, OpenCode SDK, or direct LLM
resolveExecution picks the engine from an ordered list — the nearest layer
that names one, then defaults.engine, then the implicit opencode-sdk
fallback when its binary is present — and merges one set of defaults, nearest
wins: the selected engine's own model/inference/timeout/workspace, then the
persona, the command, the invocation defaults, and the current call. Omitted,
explicit null, zero, and empty values stay distinct. A recognized
models.json alias expands once, at the layer that chose it; the request
records the exact final model. Tools are authorized against the host-local
execution.allowedTools ceiling; no asset grants itself a tool. The result's
provenance names the layer behind each field (akm task explain,
command run --dry-run).
buildExecution hands the request to the selected harness's own builder
(registered on HARNESS_REGISTRY), or builds chat messages for a direct LLM.
A tool policy the transport cannot enforce, or a denied tool selection, stops
here; any other field the transport cannot carry becomes a secret-free
untranslated-field notice and dispatch continues.
Credentials stay symbolic (an env descriptor, an apiKeyFile path, or a
secret:// reference) in everything resolveExecution returns, so a request
and runner can be journaled. runExecution() reads the current value at each
dispatch and scrubs it, with every other secret-looking value the child could
see, from the result. Workflow resume calls buildExecutionFromWire() on the
journaled { request, runner } and never reads config, models.json, or
credentials, so a config edit after the freeze cannot change a resumed unit.
The prompt-free interactive akm agent launch resolves its engine directly.
An explicit missing or incompatible engine is an error and never falls
through to another configured engine.
Task-v3 execution and durable workflow-v4 dispatch use this runtime boundary.
Markdown and GitHub-shaped YAML compile through source IR v1; new starts freeze
v4-family irVersion: 5. A stored plan that decodes runs whatever release
froze it; one that does not is marked abandoned and akm workflow run <ref>
starts afresh. Only a plan a newer akm froze is refused, naming the upgrade.
AKM does not support full GitHub Actions semantics or arbitrary remote action
execution.
In-tree LLM helpers (src/llm/)
Every helper under src/llm/ is a bounded, single-shot, stateless call.
Concretely:
- Each public export is either a pure function (
chatCompletion,splitMemoryIntoAtomicFacts,resolveIndexPassExecution,resolveIndexPassRunner,parseJsonResponse, …) or a factory that returns a one-shot client tied to the symbolic runner/config the caller passes in. - No module under
src/llm/keeps session, conversation, or response state at module scope. The only module-level singleton is the local embedder pipeline insrc/llm/embedder.ts, which is an expensive-to-build but stateless model handle (see the comment in that file). It exposesresetLocalEmbedder()so tests can construct a fresh pipeline. callStructured()is the common bounded structured-LLM seam: it adapts an already-resolved symbolic runner into an inline resolved request, lowers it, and dispatches without re-reading aliases or credentials. Index callers useresolveIndexPassExecution()to freeze the typed{ runner, notices }selection once per invocation.resolveIndexPassRunner()is its readiness projection, not a second dispatch seam. Improve processes are selected throughimprove.strategies; seedocs/reference/configuration.mdfor canonical config paths.
The seam is locked by tests/architecture/llm-stateless-seam.test.ts, which
inspects the module shape of each src/llm/* entry — not the source text.
External agents (src/integrations/agent/)
External coding agents are reachable via two execution paths:
Spawn path (src/integrations/agent/spawn.ts):
runAgent(profile, prompt, options)is the single shell-out entry point. It owns process spawn, captured/interactive stdio, hard timeout, and structured failure reasons.- The
AgentRunResultenvelope carries{ ok, exitCode, stdout, stderr, durationMs, reason?, error?, parsed? }wherereasonis one of"timeout" | "spawn_failed" | "non_zero_exit" | "parse_error". Callers never see raw process errors.
SDK path (src/integrations/harnesses/opencode-sdk/sdk-runner.ts):
runOpencodeSdk(profile, prompt, opts, llmConfig?)uses the embedded@opencode-ai/sdkinstead ofBun.spawn. No agent CLI binary is required.- Selected by an agent engine whose
platformis"opencode-sdk". Its optionalllmEnginesupplies the LLM fallback connection. With none, there is no fallback and opencode uses its own configuration;defaults.llmEngineis not borrowed. - Manages a single per-process singleton server, creating one fresh session per call to avoid history accumulation and unbounded token growth.
- Concurrent calls share startup by server material, but each call races that
startup against its own deadline (including
null); no caller's timeout is stored in the shared lifecycle.
New tasks are task source v4 .yml assets. Normal execution rejects v2 and v3
and directs operators to the explicit preview/apply migrator. Task source v4
command, workflow, script, and shell targets use the common resolved/lowered
execution boundary. Historical task-run metadata remains readable.
Long-lived mutable operations each take their own lock file, one O_EXCL
create (src/core/file-lock.ts): index writers, improve and extract process
locks, lockfile writers, the scheduler lock, and each workflow run's lock. A
lock whose holder process is gone is reclaimed. There is no shared barrier or
activity registry around them; state.db writers serialize on SQLite's own
BEGIN IMMEDIATE.
Module Boundaries
| Module | Responsibility |
|---|---|
src/cli.ts |
composition root; per-family parsing lives in commands/<family>/*-cli.ts |
src/cli/ |
citty composition helpers |
src/core/asset/asset-placement.ts |
asset type registry and canonical source directories (PLACEMENT_SPECS) |
src/core/asset/asset-ref.ts |
asset ref parsing and normalization (parseBundleRef) |
src/core/config/config.ts |
config loading, validation, env resolution |
src/core/errors.ts |
error classes with stable codes and hints |
src/core/parse.ts |
shared JSON parsing: think/fence stripping, balanced-brace extraction |
src/core/concurrent.ts |
bounded concurrency pool (concurrentMap, default 1 worker) |
src/core/write-source.ts |
the single write helper (branches on source.kind) |
src/execution/resolved-request.ts |
the versioned resolved execution request and its canonical wire form (tolerant decode) |
src/integrations/agent/execution.ts |
resolveExecution (engine, layers, model alias, tools) and buildExecution / buildExecutionFromWire |
src/integrations/agent/runner-dispatch.ts |
runExecution: reads credentials at dispatch, runs agent/SDK/LLM, redacts the result |
src/integrations/agent/request-lowering.ts |
shared factory for the harness-owned request builders |
src/sources/provider.ts |
minimal SourceProvider interface |
src/sources/providers/ |
filesystem / git / website / npm implementations |
src/sources/resolve.ts |
filesystem path resolution for refs |
src/indexer/indexer.ts |
walking, metadata generation, index rebuilds, embeddings, utility recompute |
src/indexer/walk/ |
walker, matchers, path/file/index context — the walk phase |
src/indexer/db/ |
llm-cache — the persistence phase (entries/embeddings persistence lives in src/storage/repositories/) |
src/indexer/search/ |
db-search, ranking, search-fields, search-source, enrichers — the search phase |
src/indexer/passes/ |
memory-inference, dir-staleness, metadata — LLM/metadata passes |
src/indexer/usage/ |
usage-events |
src/commands/read/search.ts |
akm search orchestration |
src/commands/read/show.ts |
akm show orchestration |
src/commands/improve/ |
knowledge-evolution slice (improve/consolidate/distill/extract/reflect + memory/) |
src/commands/proposal/ |
proposal-queue slice (proposal/propose + validators/ core 3-cycle) |
src/commands/sources/ |
source/stash lifecycle command surface |
src/commands/env/ |
env/secret command surface |
src/commands/tasks/ |
scheduled-task command surface |
src/commands/agent/ |
contribute/agent command surface |
src/registry/providers/ |
registry provider implementations (static-index, skills-sh) |
src/output/shapes/, src/output/text/ |
JSON-envelope and text-output registries per command (#490) — a parallel concern to src/output/renderers.ts's per-asset-type show renderers, which is still live and self-registering, not replaced |
src/workflows/authoring/ |
workflow authoring + scope-key helpers |
src/workflows/runtime/runs.ts |
workflow run persistence (raw SQL lives in src/storage/repositories/workflow-runs-repository.ts) |
src/workflows/runtime/ |
run lifecycle: runs, checkin, agent-identity |
src/llm/client.ts |
OpenAI-compatible chat completions client (stateless, single request/response) |
src/llm/index-passes.ts |
per-pass LLM config resolution for akm index |
src/llm/memory-infer.ts |
atomic-fact split helper (selected through improve.strategies.<name>.processes.memoryInference) |
src/llm/embedder.ts |
local + remote embedder facade with cached pipeline |
src/integrations/agent/spawn.ts |
agent CLI shell-out entry point (runAgent) |
src/integrations/harnesses/opencode-sdk/sdk-runner.ts |
embedded SDK runner selected by an SDK RunnerSpec |
src/integrations/agent/runner-dispatch.ts |
low-level exhaustive RunnerSpec transport dispatch and dispatch-time credential redaction |
src/integrations/agent/profiles.ts |
internal spawn descriptors used after agent-engine lowering |
src/integrations/agent/engine-resolution.ts |
named engine and symbolic transport-material resolution; exact request model projection happens in execution lowering |
src/llm/structured-call.ts |
bounded structured-LLM adapter through the common resolved/lowered execution seam |
src/integrations/agent/detect.ts |
PATH-based agent CLI detection for akm setup |
Tech Stack
- Runtime: Bun
- Language: TypeScript (ESM, strict)
- Database:
bun:sqlitewith FTS5; vectors are float32 BLOBs scanned in JavaScript - Testing:
bun:test - Formatting/linting: Biome