Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

This is the crabtalk development book — the knowledge base you check before building. It captures what crabtalk stands for, how the system is shaped, and the design decisions that govern its evolution.

For user-facing documentation (installation, configuration, commands), see crabtalk.ai.

How this book is organized

  • Architecture — How the system is shaped: the layers, and how to decide which one a thing belongs to. The pages under it specify each part.
  • RFCs — Design decisions and features.

RFCs

Code tells you what the system does. Git history tells you when it changed. RFCs tell you why — the problem, the alternatives considered, and the reasoning behind the choice. When you’re about to build something new, RFCs are where you check whether the problem has been thought through before.

Not every change needs an RFC. Bug fixes, refactors, and small improvements go through normal pull requests. RFCs are for decisions that establish rules, contracts, or interfaces that others need to know about before building.

Format

Each RFC is a markdown file with the following structure:

  • Header — Feature name, start date, link to discussion, affected crates.
  • Summary — One paragraph describing the decision.
  • Motivation — What problem does this solve? What use cases does it enable?
  • Design — The technical design. Contracts, responsibilities, interfaces.
  • Alternatives — What else was considered and why it was rejected.
  • Unresolved Questions — Open questions for future work.

Lifecycle

  1. Open an issue on GitHub describing the feature or design problem.
  2. Implement it. Iterate through PRs until it’s merged.
  3. Once merged, write the RFC documenting the decision and add it to SUMMARY.md.

The RFC number is the issue number or the PR number that introduced the feature. RFCs are written after implementation, not before — they record decisions that were made, not proposals for decisions to come.

Architecture

Crabtalk is a daemon, a library, and a sandbox. Which of the three you are using decides what you can extend and how — and most design arguments here turn out to be arguments about which one someone means.

The layers

protocol     clients, over a socket    any language, untrusted
harness      what shapes an agent      declared per agent, sandboxed or compiled in
capability   what an agent reaches     bounded by its argument
runtime      lifetime orchestration    turns, conversations, the agent loop
store        data                      one key-value primitive, everything above it free

The line worth getting right is between the middle two.

A harness shapes an agent: what it remembers, what skills it can load, what it knows of its own past conversations, the tools it habitually reaches for. Change them and it is a different agent. That is why they are declared per agent — an agent is its harnesses.

A capability is a mechanism for reaching something that is not the agent: fs and exec for the machine, http for the network, MCP for other software, and berm.call for another harness.

The protocol is what clients speak over the socket. A harness reaches the runtime through one capability per operation instead — peers, sessions, skills — each narrowing by existing rather than by a check run over a decoded message. sessions::search cannot name an agent because it takes a search and returns hits, and there is no field in it to mean anything else.

runtime owns the architecture of lifetime — turns, conversations, the agent loop — and nothing else. store owns the data, and owns it behind a single primitive: implement five key-value methods and every interface above them — agents, sessions, memory, skills, harnesses, search — comes for free.

Sandboxed and compiled in

A harness image is no_std for RV64 and nothing else: a sandboxed ELF the daemon schedules, reaching the world only through capabilities its declaration bounded — the absence of a capability from its linker is the enforcement. There is no std build of one. Off its target the crate still compiles, so cargo test can run the tools natively, but the capabilities there are berm-lang stubs a test arranges rather than anything that reaches out.

A compiled-in harness is native Rust inside the daemon, with the whole of std. MCP and memory are the two. It shares the Harness trait with an image and nothing else — not the source, not the confinement: there is no linker to omit from, so what bounds it is what it was written to do.

So the two are different kinds rather than two builds of one thing, and what decides which a feature can be is state. An image gets a fresh heap every invocation and persists through fs like anything else, so anything holding something alive between calls cannot be one. MCP holds live connections; MCP is compiled in and only compiled in.

Where does a thing go?

Three questions, in order. They are independent, and a feature can want more than one answer.

1. Does the daemon own the state? If not, it cannot be protocol — there is no question a client could ask that the daemon knows the answer to. Web search is the clean example: everyone has it, but the daemon holds no search state, so it is a harness and never a message.

2. Does it shape the agent, or reach past it? What an agent remembers, loads, and knows of its own history shapes it — that is a harness, declared by the agents that want it. A mechanism for touching something outside is a capability, bounded by its argument.

3. Does it hold anything alive between calls? If yes it can only be compiled in, because an image gets a fresh heap per invocation. Persisting through fs does not count — a file outlives the call. A live connection does not.

Arguments, not lists

The recurring rule, and the one worth defending hardest:

The argument is the capability. Without one it is never registered.

root is the argument to fs and exec; hosts is the argument to http. An under-specified declaration reaches nothing rather than everything, and there is no separate list of permitted names to keep in step with it — a list could only ever restate what the arguments already decided, or contradict them.

What a published image calls is the image’s business. One that never calls a capability needs no stopping; one that does is an image for exactly that, and choosing to install it is the decision. A shell-less environment is an image with no shell tool, not a shell tool with its host call withheld.

And a harness never chooses its own scope: SearchSessions carries an agent filter, and the host overwrites it with whoever declared the harness. Refusing a wrong value would only teach the harness to send the right one.

berm is not a crabtalk feature

The sandbox lives in its own repository, berm, and reaches this one from crates.io: berm host-side, berm-lang for guests. Neither knows a crabtalk crate exists.

crates/berm is crabtalk’s side: what surfaces sandboxed tools, and the crabtalk.* capabilities. Anything host-specific belongs there. http lives there rather than in the engine because hyper needs a reactor and the engine is sync and has none — keeping the engine dep-light is keeping it portable.

Prose

The daemon supplies none. An agent’s description is its system message, used verbatim; there is no default prompt and no framing wrapped around it.

What a model needs in order to reach for a tool is the tool’s own description, or the usage its harness declares — a few lines about when to reach for these tools and how they go together, which is the question no single tool description can answer because it is about choosing between them. Usage is declared in .berm.abi and injected only into agents that declared the harness. Anything longer than a few lines is a skill, not usage.

Protocol

The protocol is the daemon’s surface for everything outside its process. Clients speak it over a socket. A harness does not: it reaches the runtime through one capability per operation, and the host builds the ClientMessage on its behalf.

Addressing

A conversation is addressed by the pair (agent, sender).

  • agent names an agent registered in the daemon.
  • sender is a client-provided string identifying the counterparty — "user", "event:deploy.done", a delegate id. Clients choose their own convention.

The pair is a conversation’s only externally addressable name. The wire carries no conversation identifier. The u64 the runtime keys its live map by is internal and never leaves the process.

MessageEffect
StreamMsgAppend user content, run the agent, stream the response.
SendMsgThe same, returning one complete response rather than a stream.
KillMsgDrop the live conversation, if any.
CompactMsgCompact the current history into an archive.
CancelStreamMsgStop the in-flight run, if any. Steering is a client composition of cancel + stream.

StreamMsg.sender is optional; when omitted the daemon resolves a default determined by the transport. StreamMsg.guest selects who speaks on this turn without changing whose conversation it is.

There is no working directory on the wire. StreamMsg.cwd was removed: the daemon does not read the user’s filesystem, and a client that wants local context renders it into content itself.

A client declares no tools either. SendMsg.tools and StreamMsg.tools carried schemas the daemon advertised to the model and invoked back over the stream; both are reserved field numbers now, along with the forward event and its reply. A tool runs where the runtime does, which is what a harness is for.

One capability per operation

A harness reaches the runtime through a capability per operation — peers::list, sessions::search, skills::list, skills::get — rather than through one that carries any message. Each narrows by existing: sessions::search takes a search and returns hits, and there is no field in it that could name an agent or spend a token. Nothing is checked on decode, because there is nothing to decode a policy out of.

Each also owns its reply, which is where narrowing lives. peers::list returns agents with AgentInfo.config cleared, because a config carries its MCPs by value — env and a literal Authorization header among them.

Where a request carries a scope the harness should not choose — SearchSessions.agent — the host overwrites it rather than validating it. Refusing a wrong value would only teach the harness to send the right one.

Anything destructive, and anything that answers on someone else’s behalf, is behind no capability at all. Reaching another agent is a turn spent on its behalf, so peers names them and stops there.

Transports

The daemon accepts client messages on its transports and produces a stream of server messages in response. Each message is handled independently, with no central event loop mediating between the transport and the operations.

Entry point

Every transport (UDS, TCP, future additions) feeds ClientMessage values into the same dispatch callback. The callback spawns a Tokio task per message and polls the resulting stream, forwarding each ServerMessage back to the transport’s reply channel. When the stream ends or the reply channel closes, the task terminates.

Concurrency is unbounded at this layer: nothing throttles or serializes incoming messages before they reach their handler.

Dispatch function

Server::dispatch(ClientMessage) -> Stream<ServerMessage> is the single entry into the daemon’s operations. It inspects the ClientMessage variant and routes to the corresponding method on the Server trait.

It is also what a harness comes back through, one remove away: a runtime capability builds the ClientMessage its operation means and takes the one reply, so the daemon answers a harness exactly as it answers a client. The harness supplies a search, not a message.

  • Request-response operations (ping, kill_conversation, compact_conversation, administrative calls) yield exactly one ServerMessage.
  • Streaming operations (stream, subscribe_events) yield many ServerMessage values over time.
  • Unknown or empty messages yield a single error response.

The function is defined once in the core Server trait. Any implementor — the daemon, a test harness, a future alternative server — routes client messages the same way.

No central event loop

There is no serializing queue, no DaemonEvent enum, and no actor that owns mutation. Operations reach into shared state directly and hold locks for the duration of the critical section.

Shared state is protected by parking_lot::Mutex or parking_lot::RwLock. Event bus subscriptions and the live session registry each live behind their own lock. Locks are acquired, the work is done, and the lock is released. Ordering between operations is whatever Tokio’s scheduler produces.

Ordering guarantees

Within a single conversation, message ordering is total: StreamMsg appends to history in the order the daemon receives them. Clients that require strict ordering for a conversation are responsible for serializing their own sends.

Between conversations, no ordering is guaranteed. Two StreamMsg values addressed to different (agent, sender) pairs may run in either order regardless of arrival time.

Cancellation

KillMsg cancels the in-flight run for its (agent, sender) pair. Cancellation propagates through the runtime to the active agent step, interrupting tool calls and LLM requests at the next await point. Already-emitted ServerMessage values are not retracted.

A cancelled conversation remains valid. The next StreamMsg for the same pair resumes against the history as it existed at the point of cancellation.

Event bus

The event bus is a subscription table, not a router. publish(source, payload) iterates subscriptions, invokes the fire callback for each match inline, and removes any subscription marked once. The callback fires under the bus’s lock; implementations must not reacquire it.

The bus has no queue and no scheduler. Fan-out is as fast as the callback runs for each matching subscription.

Storage

The daemon persists through one primitive. A store implements KVStorage — get, put, delete, scan_keys, scan — and is thereby already an Agents, a Sessions, a Memory, a Skills, a Harnesses and a TextSearch, because each of those traits is bounded on KVStorage, carries its own method bodies, and is blanket-implemented for anything satisfying it.

There is nothing to construct and nothing to wire. The runtime names one bound, Config::Storage: Backend, and never learns which store it got.

KVStorage                          five methods, the only thing implemented
  └─ TextSearch: KVStorage         BM25 over the keyspace
       ├─ Agents                   blanket over KVStorage
       ├─ Skills                   blanket over KVStorage
       ├─ Harnesses                blanket over KVStorage
       ├─ Memory                   blanket over KVStorage + TextSearch
       └─ Sessions                 blanket over KVStorage + TextSearch

crates/store links no database and no search crate. Which store to run is a deployment decision, so it lives in the application: apps/agent is that wiring, and it is five methods over lib/crabdb.

The keyspace

Agent    agent/{id}                              AgentConfig
         idx/agent/{name}                        id
Session  session/{handle}/meta                   SessionMeta
         session/{handle}/archive                memory entry name
         session/{handle}/msg/{idx:012}          HistoryEntry
         session/{handle}/evt/{idx:012}          EventLine
         idx/sess/{agent}/{by}/{created_at}/{h}  handle
Memory   memory/{name}                           MemoryEntry
Skill    skill/meta/{name}                       SkillSummary
         skill/body/{name}                       SKILL.md
Harness  image/{digest}                          ELF
         name/{name}                             digest
Config   default_agent                           id
Text     idx/text/{ix}/doc/{key}                 len, weight, terms
         idx/text/{ix}/term/{term}/{key}         term frequency
         idx/text/{ix}/stats                     doc count, total length

Column — the left-hand names — is a hard partition: a scan in one never sees another’s keys, and a backend may store one differently from the rest if it wants to.

Every key opens with a realm. One realm is one store today, so it buys nothing; it is in the format from the first byte so a backend serving many is a different KVStorage implementation rather than a key migration, and so a read outside the realm is inexpressible rather than merely forbidden.

Indexes are keys

An ordered lookup, a name resolution, a set membership — all of them are secondary indexes, and a secondary index is just more keys. Nothing here needs a query planner.

created_at is RFC3339 and sorts lexicographically, so the newest session for an identity is the last key under its prefix: find_latest_session is a prefix scan and a .last(). agent_ids reads ids straight out of the name index, already sorted by name, without opening a single config. Message indices are zero-padded to twelve digits because keys sort as bytes, and "10" would otherwise come before "2".

Two key shapes appear, each chosen by its dominant access. A session’s keys nest under its handle, so deleting one is a single prefix sweep. A skill’s identity and its body are separate keys, so a listing reads names without touching markdown — a property of the layout rather than a rule each backend has to remember.

Config, whole

An agent is stored as its AgentConfig, serialized whole, with a separate idx/agent/{name} key pointing at its id.

Nothing queries inside a config, so a field-per-column layout would buy no index and cost a migration every time the struct gains one — and it does gain them (mcps went from Vec<String> to full configs; harnesses arrived later). The name index exists because a person types names; everything else addresses an agent by id, which is why renaming one moves nothing but a label.

The install’s own config.toml is not in the store. It is hand-written and read from disk on every reload. The one value the daemon decides rather than reads — which agent is default — is store state under Config, because a field a program rewrites inside a file a person owns is two sources for one value.

Sessions

A session is a conversation’s persistent form, addressed by an opaque SessionHandle. The handle encodes nothing — not the agent, not the sender, not a date — so renaming an agent never orphans its transcripts.

  • Messages — the HistoryEntry stream, one key per entry, appended.
  • Events — the EventLine trace.
  • Meta — title, timestamps, message count, summary.
  • Archive — a pointer to the memory entry holding a compacted prefix. The marker carries the pointer; the summary text lives in memory, never beside the session.

Writes are appends. truncate_session_messages is the only operation that removes history, and append_session_compact records the boundary.

Ranked full-text is the one lookup keys cannot answer, so it is the one thing built on top — though its index is keys too, since an inverted index is a map from term to documents and a map is what a keyspace is.

TextSearch is four operations that know nothing about what they index: a key, a string, and a number to weight by. Whoever wants a person’s own words to outrank a tool’s passes a larger weight; what a “role” is stays in Sessions.

A document’s record names its own terms, so retracting one touches its own postings rather than walking the index. A query term ending in * prefix-matches — free, when terms are keys — and is the nearest thing to stemming on offer: deploy* finds “deployment” and “deployed” where deploy finds neither. Phrase search is deliberately absent; it would need positional postings on every write, and the tokenizer drops stopwords, so a phrase query would be quietly wrong rather than unsupported.

What may be indexed at all is decided by HistoryEntry::indexable. Tool results and tool-call arguments are excluded, because both carry credentials often enough that neither belongs in free text a query can reach; a tool-calling assistant contributes only its function names.

crabdb

lib/crabdb is the shipped store: an append-only single file, no dependencies, and a bar of “better than a directory of files” rather than “beats a database.”

header   32 bytes, fixed, rewritable in place
         "CRMEM\0" | version | flags | reserved | index_at | index_len
record   op | col | key_len | key | val_len | value
index    count | repeated { col | key_len | key | offset }

Records are appended and never edited; the newest record for a key wins. A resident BTreeMap<(col, key), offset> makes a lookup one seek and a prefix scan an ordered walk. The map holds offsets rather than values, so residency tracks how many keys exist rather than how much has been written — a four-megabyte harness image costs the same entry as a four-byte posting.

The header is fixed and the index is not, so the header holds a pointer and the snapshot sits wherever it last fit. On open the snapshot loads and only records appended after it are replayed. A record torn by a crash ends the replay, with the append position reset to the last clean boundary so the fragment is overwritten. Compaction rewrites live records to a sibling file and renames, so a crash during compaction costs the work and nothing else.

Durability is stated rather than assumed: writes reach the OS immediately, so a process crash loses nothing; fsync happens on checkpoint and compaction, so a power loss can lose writes since the last one. That is what keeps posting writes cheap, and the text index writes many small records per message.

Tuning

Ranking numbers are judgements, so the store is asked for them rather than having them fixed. Sessions::config() -> Weights carries role weights, title and summary boosts, and how many message matches to pull per requested hit. TextSearch::bm25() -> Bm25 carries k1 and b. Both have defaults, and because both traits are blanket-implemented the defaults are what every store gets today.

See RFC 0207 for the design and the alternatives it rejected.

Runtime

The runtime owns the architecture of lifetime: it holds live conversations, runs agent steps, dispatches tool calls, and applies compaction. It does not open sockets, accept connections, or schedule time. Capabilities that require I/O are provided to the runtime by its environment.

It is also the thing that eats harnesses. Features do not belong here — a feature that ends up as a runtime field is a feature that leaked into the lifecycle engine.

Composition

A runtime is parameterized by a Config that names three associated types:

TypeResponsibility
StorageThe store: one KVStorage impl, and every interface built on it.
ProviderLLM request and streaming.
EnvNode-specific capabilities and tool dispatch.

A binary supplies one Config. The shipped Config wires crabdb, a configured provider, and a node environment that owns harnesses and event broadcasting. Tests supply a Config with an in-memory store, a stub provider, and () as the environment.

The runtime holds interfaces, never data. There is no agent registry and no memory handle: an agent is read from the store for the run that needs it, built, and dropped. Whether any of it is cached is the store’s decision, which is what makes a different deployment a different implementation rather than a rewrite of the runtime. The exception is a live session, which holds a cancellation token — a token cannot be persisted, so it is genuinely per-process state.

Responsibilities

The runtime handles:

  • Loading and saving conversations through Storage.
  • Building an agent request from the current history, instructions, and tool schemas.
  • Streaming responses from Provider and applying them to the conversation.
  • Dispatching tool calls through Env.
  • Emitting AgentEvent values for each step, tool call, and compaction.
  • Producing compaction summaries and appending archive markers.

Conversations

A conversation is the unit of agent interaction: the message history an agent uses as working context, plus the state that travels with it. The runtime holds live conversations; the protocol addresses them by (agent, sender).

Lifetime

A conversation is created on first reference to a pair (agent, sender) that does not yet exist, and persists across daemon restarts. Persistence goes through the Sessions interface; the store behind it is the binary’s choice.

At most one conversation exists for any given (agent, sender) pair.

State

A conversation holds:

  • History — an ordered sequence of history entries.
  • Title — a short human-readable label.
  • Archive — a pointer to the compacted prefix of the history, if the conversation has been compacted (see Memory).

History ordering is total. New entries are appended; no entry is reordered or removed except through compaction.

Message attribution

Each assistant message in the history carries an agent field.

  • An empty agent field denotes a message produced by the conversation’s primary agent, the one named by the conversation’s identity.
  • A non-empty agent field denotes a guest turn (see Multi-agent).

Messages produced by the daemon for protocol framing are marked as auto-injected and stripped from the history before each run.

Guest turns

A guest turn runs a named guest agent against the primary conversation’s history and appends the guest’s response to that history. The primary agent of the conversation is unchanged.

A guest turn is requested by setting StreamMsg.guest to the name of the guest agent. The conversation is still addressed by the primary’s (agent, sender) pair; guest selects who speaks on this turn, not whose conversation it is.

Flow

When StreamMsg { agent: A, sender: S, guest: G, content: C } is dispatched:

  1. The conversation (A, S) is resolved, creating it if necessary.
  2. The user content C is appended to the history.
  3. The daemon runs agent G against the history using G’s own description, which is its system message.
  4. The response is appended to the history, tagged with agent: G.

The primary agent is not invoked on a guest turn. A subsequent StreamMsg without guest resumes normal operation with the primary agent against the updated history.

Tools on guest turns

A guest turn is text-only. The guest agent’s tool schemas are not attached to the request, and any tool call emitted by the guest is rejected.

Tool-using work belongs to the primary agent. A guest is a voice in the conversation, not a worker.

Framing

When building a request, the runtime auto-injects framing messages that are not persisted between runs. Two framings exist:

  • Guest framing. Injected when a guest is running. It tells the guest that it is joining a conversation and explains the <from agent="..."> tag convention.
  • Primary framing. Injected when the primary is running and the history contains at least one message with a non-empty agent. It tells the primary that some messages are from guest agents and it should continue responding as itself.

Framing messages are marked auto-injected. They are stripped from the history at the start of each run and re-injected for that run only. The history on disk never contains framing messages.

Tagging

Assistant messages with a non-empty agent field are prefixed with <from agent="{name}"> when they appear in an LLM request. The prefix makes the speaker visible to whichever agent is currently reading the history.

A message without an agent field carries no prefix.

Boundary

The runtime does not:

  • Bind listeners or accept transport connections.
  • Spawn tasks for message routing or scheduling.
  • Interpret protocol messages.
  • Read the system clock for scheduling purposes.
  • Manage process state such as PID files or signals.

These belong to the server that hosts the runtime.

Env

Env is the runtime’s only outward-facing capability surface. It provides:

  • hook() — the composite Harness that exposes tool schemas, dispatches tool calls, and participates in lifecycle events.
  • on_agent_event(agent, conversation_id, event) — hook point for side effects, such as event broadcasting or persistence of step traces.
  • subscribe_events() — optional subscription to a cross-conversation event stream, for servers that expose agent events to external clients. Methods that the runtime does not need in a given context have default implementations. An Env implementation may leave event broadcasting at its default.

Instruction discovery and working-directory resolution are not here. The daemon does not read the user’s filesystem — a client renders local instructions into the message it sends, and a harness reaches files through the root its declaration names.

Harness

A harness is a way to serve a tool call the daemon does not implement itself.

Harness is public API at the runtime layer, and that is the point: an embedder using crabtalk as a library implements it, registers it through the composite, and gets tools in their own process without running a daemon. That is a consumer the protocol cannot serve — a client on a socket and a crate in your binary want different things.

Harness is the single point through which the runtime reaches node-specific tools. A harness:

  • Advertises tool schemas for the LLM request.
  • Dispatches tool calls by name, returning a future that yields the tool’s result.
  • Participates in step lifecycle, observing starts, completions, and errors.

It is composite: the daemon’s owns sub-harnesses, and the runtime sees a single Harness. Order is fixed by the composite.

usage is the one declaration worth calling out — what these tools are for, when to reach for them, and how they go together. It is the question no single tool’s description answers, because it is about choosing between them.

Why two ship

dispatch is the only method on the trait that genuinely requires it — schema and usage are declarations, and the rest is lifecycle bookkeeping. So the question “how many implementations are there” is really “how many ways can a tool call be served that the daemon does not implement”, and there are two: inside a sandbox or over the network (MCP). A third would need a third execution substrate.

An embedder adding their own is not a violation of that count. It is the seam working.

Tool dispatch

A tool call from the agent carries the tool name, arguments, the originating agent and sender, and the conversation id. The runtime invokes Env::hook().dispatch(name, call). If no sub-harness claims the name, the dispatch yields an error result; the agent receives the error as the tool’s output.

Dispatch is asynchronous. The runtime awaits the tool future at the next step boundary and applies the result to the conversation before the following step.

Harnesses

A harness is code the daemon schedules: one hash-pinned RV64IMAC ELF, compiled and run in-process, confined to its own address space, reaching the world only through host calls it was given. It never runs of its own accord — the daemon decides when, and while running it may call back in.

A harness is what shapes an agent — what it remembers, what it can load, what it knows of its own past, the tools it habitually reaches for. An agent is its harnesses, which is why each one is declared per agent rather than shipped to everybody.

This chapter describes harness images. The daemon also carries compiled-in harnesses — MCP and memory — which share the trait and none of the confinement; see Architecture.

The argument is the capability

Host calls are keyed by number, and a number with nothing registered traps. So a capability the declaration did not bound is not checked for — it is absent from the linker the harness was instantiated with. Enforcement is the absence of code. There is no check to write and none to forget.

Every capability takes an argument, and the argument is not optional decoration — it is the capability. Without the argument it is never registered, so an under-specified declaration reaches nothing rather than everything.

CapabilityReachesBounded by
fsFilesroot
execCommandsroot
httpThe networkhosts
peersThe other agents’ names—
sessionsThe agent’s own past conversationsthe declaring agent
skillsThe skills the agent namedskills
berm.callAnother harness the same agent declaredthe declaring agent

The runtime is reached through one capability per operation rather than one carrying every message. Each narrows by existing: sessions::search cannot name an agent, because it takes a search and returns hits and there is no field in it to mean anything else.

That is also why the daemon’s own port is not a way back in: http can only reach a name written in hosts, so localhost is unreachable unless somebody put it there.

Calling another harness

berm.call takes a harness name, a tool, and the argument blob a model would have sent. The name is resolved per call against the declaring agent’s own resolution, so what a name means is what that agent declared it to mean — two agents installing different images under one name reach their own.

A caller can tell a target that ran and failed from one that never ran: the first is the tool’s own failure, the second a refusal. There is no depth bound. The watchdog bounds a chain on time rather than on links, so a cycle reaches the host thread’s stack first.

The image is the capability

There is no list of permitted capabilities beside those arguments. A published harness is a fixed ELF: one that never calls a capability does not need to be stopped from calling it, and one that does is a harness for exactly that. To run a shell-less environment, install an image with no shell tool — a tool that is absent cannot be called, where a tool present but starved of its host call is only a broken tool.

Manifest, not inference

A harness carries .berm.abi, an ELF section holding its ABI version, tools, and usage. A section rather than an export, because learning what a harness claims to be must not mean running it — the daemon reads a tool list, a schema, and usage text out of the file without compiling anything.

Images are content-addressed

An image is keyed by a digest of what determines it: the ELF, the root and hosts bounding it, the session it resolved under, and the Scope its runtime capabilities close over. Not by the agent that declared it.

Two agents that declare the same ELF against different roots hash differently and get two linkers. Two that declare it identically share one image. A rename changes nothing, because the agent’s name was never part of the key — but a per-agent narrowing is part of it, so two agents holding the same session harness deliberately get two images rather than sharing one narrowing.

The session is in the key for berm.call, which closes over the resolution a name is looked up in. Only a session-rooted declaration reaches the bound root, so without it a rootless or fixed-root image would be shared across sessions while resolving its siblings against whichever one compiled it first.

Invocation

Memory is per-invocation: a fresh store each call, nothing surviving between them. Anything a harness needs to persist belongs in a capability, not in its heap. The boundary costs roughly 17µs; compiling an image is ~15ms cold and ~3ms against the on-disk code cache, paid per image rather than per call.

Entering a harness blocks the thread it runs on, and exec can hold it for the length of a command, so dispatch hands the invocation to the blocking pool. A watchdog bounds how long a harness may run, set to outlast the longest host call a capability may make.

Daemon

The daemon is the long-lived process that hosts the runtime, owns transports, and persists state. Clients are transient; the daemon is not. A single daemon process serves all configured agents, all active conversations, and all connected clients.

Responsibilities

The daemon owns:

  • Transports — UDS and TCP listeners. Listening endpoints belong to the daemon, not to individual clients or agents.
  • Runtime — a single shared runtime instance behind RwLock. Agents share the runtime; the runtime is never cloned per conversation.
  • Harnesses — the composite Harness. Three ship today: the one surfacing the tools of each agent’s declared harness images, MCP, and memory. Harness is public API at the runtime layer, so an embedder registers their own.
  • Event bus — subscription table and fire callback. File-backed by events/subscriptions.toml under the config directory.
  • MCP handler — connections to external MCP servers and routing to the tools they advertise.
  • Harness images — compiled RV64 ELFs, keyed by a content digest of the ELF, the arguments bounding its capabilities, the session it resolved under, and the scope those capabilities close over.
  • Configuration — current DaemonConfig, reloaded in place on explicit reload.

The daemon does not interpret tool semantics. Tool dispatch is the runtime’s responsibility, routed through the composite.

The daemon owns no tools of its own. bash, read and edit are a harness an agent declares. What the daemon supplies is the socket, the runtime, and the state — not a set of capabilities it decided every agent should have.

Process model

The daemon runs as a single OS process. All work happens on a single Tokio runtime. There is one listener task per configured transport, one reply task per connected client, and one task per in-flight dispatch. Shutdown is initiated by a broadcast channel; every long-lived task subscribes and exits when the channel fires.

A daemon process owns at most one configuration directory and at most one set of transport endpoints.

Config directory

The daemon is rooted at a configuration directory supplied at startup — $CRABTALK_HOME, else ~/.crabtalk. Two daemons with different roots share nothing: the store, the socket and the port file all hang off it. The directory holds:

PathContents
config.tomlNode configuration, hand-written and read on reload.
store.crmemThe store: agents, sessions, memory, skills, search.
events/subscriptions.tomlEvent subscription recovery file.

All paths are resolved relative to the configuration directory. The daemon writes nothing outside this directory.

Lifecycle

Startup. The daemon reads config.toml, constructs the provider, assembles harnesses, opens storage, builds the shared runtime, loads event subscriptions from disk, binds transports, and begins accepting client messages.

Runtime. The daemon serves the Server trait. Each client message is dispatched into a spawned task that produces a stream of server messages.

Reload. A ReloadMsg causes the daemon to re-read config.toml and rebuild the shared runtime in place. Existing in-flight dispatches complete against the previous runtime; new dispatches see the reloaded runtime. Transports are not re-bound.

Shutdown. SIGTERM or SIGINT broadcasts a shutdown signal. Transport listeners stop accepting new connections, active dispatches complete or cancel at the next await point, and the socket and port file are removed. State was persisted on each mutating operation, so nothing is written at exit that a caller was not already acknowledged for; the store is checkpointed, which is durability and startup cost rather than new state.

Persistence boundary

The daemon persists through the store. Operations that mutate conversations, memory, or agent definitions write before acknowledging the caller. Cron and event subscription files are written directly by the daemon.

A write reaches the OS immediately, so a process crash loses nothing. fsync happens at a checkpoint, so a power loss can lose writes since the last one — see Storage.

A daemon restart recovers all state from the config directory. No state is held only in the process.

Client addressing

Clients do not address the daemon. Clients connect to a transport and send ClientMessage values. The transport’s reply channel delivers ServerMessage values back until the connection closes. A client that reconnects and addresses the same (agent, sender) pair resumes the same conversation; no client-side resume token is required.

Providers

Providers are the sole point of contact between the daemon and an LLM. The provider layer is external: its trait, types, and concrete implementations live upstream in crabllm. Crabtalk consumes providers but does not define them.

Boundary

The crabllm-core crate defines the Provider trait and the shared types that flow across it: ChatCompletionRequest, Message, Tool, ToolCall, Role, Usage, ApiError. These types are the contract between crabtalk and any LLM backend.

The crabllm-provider crate defines concrete provider implementations. ProviderRegistry assembles them and yields one Provider value constructed from the node configuration.

Crabtalk depends on both crates as external dependencies. It does not vendor provider code. Changes to provider internals — authentication, request formatting, streaming, error decoding, retry policy — are made upstream.

Usage

A runtime is parameterized by Config::Provider. The daemon’s default config resolves Provider by calling ProviderRegistry::build with the user’s configuration. The runtime holds a single provider instance for its lifetime and calls it once per agent step.

The provider is asked to produce:

  • A non-streaming completion for synchronous operations.
  • A streaming completion for StreamMsg operations, yielding chunks that the runtime accumulates into a Message.

The runtime does not interpret provider-specific errors. ApiError is surfaced to the client as a protocol error; the provider is responsible for mapping backend failures into ApiError values.

Tools across the boundary

Tool schemas are declared in crabllm-core::Tool. The runtime collects schemas from the composite hook, attaches them to the request, and lets the provider format them for the backend. A harness’s schemas are read out of its ELF’s manifest section rather than by running it, so a tool list costs no execution. Tool calls returned by the provider arrive as ToolCall values; the runtime dispatches each call through Env::hook().dispatch.

The shape of tool schemas is fixed by crabllm-core. A tool that cannot be expressed in that shape is not expressible to crabtalk.

Configuration

Provider configuration is read from the node’s config.toml and passed to ProviderRegistry. The daemon does not inspect provider-specific configuration; it forwards the relevant sections to the registry and accepts the resulting Provider.

Adding a new backend is a change to crabllm-provider. It is not a change to crabtalk.

Upstream

crabllm is maintained at crabtalk/crabllm. Bug fixes, new backends, and trait changes are filed there. Crabtalk upgrades its crabllm dependency on release.

0009 - Transport

  • Feature Name: UDS and TCP Transport Layers
  • Start Date: 2026-03-27
  • Discussion: #9
  • Crates: transport, core

Summary

A transport layer providing Unix domain socket (UDS) and TCP connectivity between clients and the crabtalk daemon, built on a shared length-prefixed protobuf codec defined in core.

Motivation

The daemon needs to accept connections from local CLI clients and remote clients (Telegram, web gateways). UDS is the natural choice for same-machine communication — no port management, filesystem-based access control. TCP is required for remote access and cross-platform support (Windows has no UDS).

Both transports share identical framing and message types. The codec and message definitions belong in core so that any transport can use them without depending on each other. The transport crate provides the concrete connection machinery.

Design

Codec (core::protocol::codec)

Wire format: [u32 BE length][protobuf payload]. The length prefix counts payload bytes only, excluding the 4-byte header itself.

Two generic async functions operate over any AsyncRead/AsyncWrite:

  • write_message<W, T: Message>(writer, msg) — encode, length-prefix, flush.
  • read_message<R, T: Message + Default>(reader) — read length, read payload, decode.

Maximum frame size is 16 MiB. Frames exceeding this limit produce a FrameError::TooLarge. EOF during the length read produces FrameError::ConnectionClosed (clean disconnect, not an error).

Server accept loop

Both UDS and TCP servers share the same pattern:

accept_loop(listener, on_message, shutdown)
  • listener — UnixListener or TcpListener.
  • on_message: Fn(ClientMessage, Sender<ServerMessage>) — called for each decoded client message. The sender is per-connection; the callback can send multiple ServerMessages (streaming responses) or exactly one (request-response). The channel is unbounded because messages are small and flow-controlled by the protocol — the agent produces responses at LLM speed, far slower than socket drain speed.
  • shutdown — oneshot::Receiver<()> for graceful stop.

Each accepted connection spawns two tasks: a read loop that decodes ClientMessages and calls on_message, and a send task that drains the UnboundedSender and writes ServerMessages back. When the read loop ends (EOF or error), the sender is dropped, which terminates the send task.

TCP specifics

  • Default port: 6688. If the port is in use, bind fails — another daemon may already be running.
  • TCP_NODELAY is set on all connections (low-latency interactive protocol).
  • bind() returns a std::net::TcpListener (non-blocking).

UDS specifics

  • Unix-only (#[cfg(unix)]).
  • Socket path is caller-provided (typically ~/.crabtalk/daemon.sock).
  • No port management or collision handling — the filesystem path is the identity.

Client trait (core::protocol::api::Client)

Two required transport primitives:

  • request(ClientMessage) -> Result<ServerMessage> — single round-trip.
  • request_stream(ClientMessage) -> Stream<Item = Result<ServerMessage>> — send one message, read responses until the stream ends.

Both UDS Connection and TCP TcpConnection implement Client identically: split the socket into owned read/write halves, write via codec, read via codec. The request_stream implementation reads indefinitely; typed provided methods on Client (e.g., stream()) handle sentinel detection (StreamEnd).

Connections are not Clone — one connection per session. The client struct (CrabtalkClient / TcpClient) holds config and produces connections on demand.

Alternatives

tokio-util LengthDelimitedCodec. Would save the manual length-prefix code but adds a dependency for ~50 lines of straightforward framing. The hand-rolled codec is simpler to audit and has no extra allocations.

gRPC / tonic. Full RPC framework with HTTP/2 transport. Heavyweight for a local daemon protocol. The current design is simpler: raw protobuf over a length-prefixed stream, no HTTP layer, no service definitions beyond the Server trait.

Shared generic transport trait. UDS and TCP accept loops are nearly identical but kept as separate modules. A generic Transport trait would save ~20 lines of duplication but add an abstraction with exactly two implementors. Not worth it.

Unresolved Questions

  • Should the transport support TLS for TCP connections in non-localhost deployments?
  • Should there be a connection timeout or keepalive at the transport level, or is the protocol-level Ping/Pong sufficient?

0018 - Protocol

  • Feature Name: Wire Protocol
  • Start Date: 2026-03-27
  • Discussion: #18
  • Crates: core

Summary

A protobuf-based wire protocol defining all client-server communication for the crabtalk daemon, with a Server trait for dispatch and a Client trait for typed request methods.

Motivation

The daemon mediates between multiple clients (CLI, Telegram, web) and multiple agents. A well-defined wire protocol decouples client and server implementations and makes the contract explicit. Protobuf was chosen for compact binary encoding, language-neutral schema, and generated code via prost.

Design

Wire messages (crabtalk.proto)

Two top-level envelopes using oneof:

ClientMessage — 15 variants:

VariantPurpose
SendRun agent, return complete response
StreamRun agent, stream response events
PingKeepalive
SessionsList active sessions
KillClose a session
GetConfigRead daemon config
SetConfigReplace daemon config
ReloadHot-reload runtime
SubscribeEventsStream agent events
ReplyToAskAnswer a pending ask_user prompt
GetStatsDaemon stats
CreateCronCreate cron entry
DeleteCronDelete cron entry
ListCronsList cron entries
CompactCompact session history

ServerMessage — 11 variants:

VariantPurpose
ResponseComplete agent response
StreamStreaming event (see below)
ErrorError with code and message
PongKeepalive ack
SessionsSession list
ConfigConfig JSON
AgentEventAgent event (for subscriptions)
StatsDaemon stats
CronInfoCreated cron entry
CronListAll cron entries
CompactCompaction summary

Streaming events

StreamEvent is itself a oneof with 8 variants representing the lifecycle of a streamed agent response:

  • Start { agent, session } — stream opened.
  • Chunk { content } — text delta.
  • Thinking { content } — thinking/reasoning delta.
  • ToolStart { calls[] } — tool invocations beginning.
  • ToolResult { call_id, output, duration_ms, is_error } — single tool result. is_error signals the handler reported failure; output carries the text in either case so clients can render it. UIs use the flag to style errors distinctly; agents can use it for retry decisions without string-matching on error messages.
  • ToolsComplete — all pending tool calls finished.
  • AskUser { questions[] } — agent needs user input.
  • End { agent, error } — stream closed (error is empty on success).

The client reads StreamEvents until it receives End, which is the terminal sentinel.

Tool result ordering. When a single agent step produces N tool calls, the runtime dispatches them concurrently and emits ToolResult events in completion order — fast tools are reported as soon as they finish, slow siblings report later. The event stream is therefore not ordered by the call index in ToolStart.calls[]. Clients correlate by call_id, which is the primary key; do not assume positional alignment with the ToolStart call list.

Agent events

AgentEventMsg carries a kind enum (TEXT_DELTA, THINKING_DELTA, TOOL_START, TOOL_RESULT, TOOLS_COMPLETE, DONE) plus agent name, session ID, content, and timestamp. Used by SubscribeEvents for live monitoring of all agent activity across sessions. For TOOL_RESULT events, the tool_is_error field mirrors the streaming protocol’s is_error — monitoring clients use it to aggregate error rates per tool type without parsing output strings.

AgentEventMsg overlaps with StreamEvent — both represent the agent execution lifecycle. StreamEvent is the per-request streaming format (rich, typed variants). AgentEventMsg is the cross-session monitoring format (flat, single struct with a kind tag). The duplication exists because monitoring clients need a simpler, uniform shape to filter and display events from multiple agents.

Server trait

One async method per ClientMessage variant. Implementations receive typed request structs and return typed responses:

#![allow(unused)]
fn main() {
trait Server: Sync {
    fn send(&self, req: SendMsg) -> Future<Output = Result<SendResponse>>;
    fn stream(&self, req: StreamMsg) -> Stream<Item = Result<StreamEvent>>;
    fn ping(&self) -> Future<Output = Result<()>>;
    // ... one method per operation
}
}

The provided dispatch(&self, msg: ClientMessage) -> Stream<Item = ServerMessage> method routes a raw ClientMessage to the correct handler. Request-response operations yield exactly one ServerMessage; streaming operations yield many. Errors are mapped to ErrorMsg { code, message } using HTTP status codes with their standard semantics: 400 (bad request), 404 (not found), 500 (internal error).

Client trait

Two required transport primitives:

  • request(ClientMessage) -> Result<ServerMessage> — single round-trip.
  • request_stream(ClientMessage) -> Stream<Item = Result<ServerMessage>> — raw streaming read.

Typed provided methods (send, stream, ping, get_config, set_config) handle message construction, response unwrapping, and sentinel detection. The stream() method consumes events via take_while until StreamEnd and maps each frame through TryFrom<ServerMessage> for type-safe event extraction.

Conversions (message::convert)

From impls wrap typed messages into envelopes (SendMsg -> ClientMessage, SendResponse -> ServerMessage). TryFrom impls unwrap in the other direction, returning an error for unexpected variants. This keeps call sites clean — no manual enum construction.

Alternatives

JSON over WebSocket. Simpler to debug with curl, but larger payloads and no schema enforcement. Protobuf catches schema mismatches at compile time.

gRPC service definitions. Would provide streaming and code generation out of the box, but brings HTTP/2, tower middleware, and tonic as dependencies. The current approach is lighter: raw protobuf frames over a length-prefixed stream, with hand-written trait dispatch.

Separate request/response ID correlation. The protocol is connection-scoped and sequential — one outstanding request per connection at a time. This is a fundamental design constraint: clients must wait for a response before sending the next request. No need for request IDs or multiplexing. If multiplexing is needed later, it belongs in the transport layer, not the protocol.

Unresolved Questions

  • Should the protocol negotiate a version on connect to detect client/server mismatches?
  • Should StreamEnd carry structured error information (code + message) instead of a plain string?
  • Should there be a ClientMessage variant for subscribing to a specific session’s events rather than all events?

0027 - Model

  • Feature Name: Model Abstraction Layer
  • Start Date: 2026-01-25
  • Discussion: #27
  • Crates: model, core

Summary

A provider registry that wraps multiple LLM backends (OpenAI, Anthropic, Google, Bedrock, Azure) behind a unified Model trait, with per-model provider instances, runtime model switching, and retry logic with exponential backoff.

Motivation

The daemon talks to LLMs. Which LLM, from which provider, through which API — that’s configuration, not architecture. The agent code should call model.send() and not care whether it’s hitting Anthropic directly or an OpenAI-compatible proxy.

This requires:

  • A single trait that all providers implement.
  • A registry that maps model names to provider instances.
  • Runtime switching between models without restarting.
  • Retry logic for transient failures (rate limits, timeouts).
  • Type conversion between crabtalk’s message types and each provider’s wire format.

Design

Model trait (core)

Defined in wcore::model:

#![allow(unused)]
fn main() {
pub trait Model: Clone + Send + Sync {
    async fn send(&self, request: &Request) -> Result<Response>;
    fn stream(&self, request: Request) -> impl Stream<Item = Result<StreamChunk>>;
    fn context_limit(&self, model: &str) -> usize;
}
}

The trait is in core because agents are generic over Model. The implementation lives in the model crate.

Provider

Wraps crabllm_provider::Provider (the external multi-backend LLM library) behind the Model trait. Each Provider instance is bound to a specific model name and carries:

  • The backend connection (OpenAI, Anthropic, Google, Bedrock, Azure).
  • A shared HTTP client.
  • Retry config: max_retries (default 2) and timeout (default 30s).

Base URL normalization strips endpoint suffixes (/chat/completions, /messages) so both bare origins and full paths work in config.

ProviderRegistry

Implements Model by routing requests to the correct provider based on the model name in the request.

ProviderRegistry
├── providers: BTreeMap<String, Provider>   # keyed by model name
├── active: String                          # default model
└── client: reqwest::Client                 # shared across providers
  • Construction: one ProviderDef can list multiple model names. Each gets its own Provider instance. Duplicate model names across definitions are rejected at validation time.
  • Routing: send() and stream() look up the provider by request.model. Callers get a clone of the provider — the registry lock is not held during LLM calls.
  • Switching: switch(model) changes the active default. Agents can still override per-request via the model field.
  • Hot add/remove: providers can be added or removed at runtime without rebuilding the registry.

Retry logic

Non-streaming send() retries transient errors (rate limits, timeouts) with exponential backoff and full jitter:

  • Initial backoff: 100ms, doubling each retry.
  • Jitter: random duration in [backoff/2, backoff].
  • Max retries: configurable per provider (default 2).
  • Non-transient errors (auth failures, invalid requests) fail immediately.

Streaming does not retry — the connection is already established.

Type conversion

A convert module translates between wcore::model types (Request, Response, Message, StreamChunk) and crabllm_core types (ChatCompletionRequest, ChatCompletionResponse). This isolates the external library’s types from the rest of the codebase.

Alternatives

Direct provider calls without a registry. Each agent holds its own provider. Rejected because runtime model switching and centralized configuration require a shared registry.

Trait objects instead of enum dispatch. Box<dyn Model> instead of the concrete Provider enum. Rejected because Model has generic return types (impl Stream) that prevent object safety. The enum dispatch via crabllm_provider::Provider handles this naturally.

Unresolved Questions

  • Should the registry support fallback chains (try provider A, fall back to B)?
  • Should streaming requests retry on connection failures before the first chunk?

0082 - Scoping

  • Feature Name: Agent Scoping
  • Start Date: 2026-03-22
  • Discussion: #82
  • Crates: runtime, core

Updated by 0193 (Agent-Owned MCP) (2026-04-28). AgentScope.mcps was removed: agents now embed their MCP server configurations by value, so MCP scoping is intrinsic to the agent’s declaration and no separate allowlist is needed.

Updated by 0203 (Client-Side Orchestration) (2026-08-15). The tool whitelist now applies to client-provided tools at advertisement as well as at dispatch. delegate moved to the client, so it is no longer unconditionally available; “Delegate CWD isolation” describes a DelegateTask.cwd field that was never implemented.

Summary

A whitelist-based scoping system that restricts what an agent can access: tools and skills. Enforced at dispatch time and advertised in the system prompt. This is a security boundary, not a hint. MCP scoping is no longer part of AgentScope — see 0193 for the replacement model.

Delegation is not scoped: crabtalk is a single-user runtime, and any registered agent can delegate to any other. Multi-tenant identity-based access control, if ever needed, belongs in a wrapper above the runtime, not inside AgentConfig.

Motivation

In multi-agent setups, a delegated sub-agent should not have the same capabilities as the primary agent. A research agent doesn’t need bash. Without scoping, every agent has access to everything — which means a misbehaving or confused agent can call tools it was never intended to use.

Scoping solves this by letting agent configs declare exactly what resources are available. The runtime enforces it.

Design

AgentScope

#![allow(unused)]
fn main() {
pub struct AgentScope {
    pub tools: Vec<String>,     // empty = unrestricted
    pub skills: Vec<String>,    // empty = all skills
}
}

Empty list means unrestricted. Non-empty means only listed items are allowed. This is an inclusive whitelist, not a denylist. MCPs are not part of AgentScope: AgentConfig.mcps: Vec<McpServerConfig> makes the declaration itself the scope (RFC 0193).

Whitelist computation

When an agent has any scoping (non-empty skills), the runtime computes a tool whitelist during on_build_agent:

  1. Start with BASE_TOOLS: bash, ask_user, read, edit — always available.
  2. If memory is enabled: add recall, remember, memory, forget.
  3. If skills list is non-empty: add skill tool.
  4. MCP tools the agent declared in AgentConfig.mcps are always included — declaration is the gate.

The computed whitelist replaces config.tools. Tools not on the list are invisible to the agent. The delegate tool is always available — delegation is not gated by scope.

Prompt injection

A <scope> block is appended to the system prompt listing the agent’s allowed resources:

<scope>
skills: check-feeds, summarize
</scope>

This tells the agent what it can use. The agent doesn’t need to guess or discover — its boundaries are stated upfront. MCP servers are listed separately in the resource-hints block from the agent’s own mcps declaration.

Enforcement

Scoping is enforced at two dispatch points:

  • Tool dispatch — rejects tool calls not in the agent’s tool whitelist.
  • Skill dispatch — rejects skill names not in the agent’s skill list.

MCP dispatch needs no explicit gate: the agent only sees the MCPs it declared, so calls outside that set are structurally impossible.

Enforcement happens at runtime, not just at prompt time. Even if the LLM ignores the <scope> block and tries to call a restricted tool, the dispatch layer rejects it.

Sender restrictions

Not all base tools are available to all senders. bash is blocked for non-CLI senders (gateway agents from Telegram, WeChat, etc.) because it grants arbitrary shell access. read and edit have no sender restriction — they are read-only or scoped mutations that are safe for gateway agents. See #67.

Delegate CWD isolation

When delegating parallel tasks, the orchestrating agent can assign each sub-agent a separate working directory via the cwd field on DelegateTask. Tools resolve relative paths against the conversation CWD, so isolated CWDs prevent concurrent sub-agents from trampling each other’s files. The edit tool’s unique-match requirement provides a second layer: if another agent changed the file between read and edit, old_string won’t match and the edit fails — optimistic concurrency without locks.

Default agent

The default agent (primary) has no scope restrictions — empty lists on all three dimensions. Scoping is for sub-agents that need constrained access.

Alternatives

Denylist instead of whitelist. List what’s forbidden instead of what’s allowed. Rejected because allowlists are safer by default — a new tool or server is inaccessible until explicitly granted. Denylists require updating every time a new resource is added.

Prompt-only scoping. Tell the agent its restrictions in the prompt but don’t enforce at dispatch. Rejected because LLMs don’t reliably follow instructions — a determined or confused model will call tools it was told not to. Enforcement must be at the dispatch layer.

Unresolved Questions

  • Should scoping support wildcard patterns (e.g. mcp: search-*)?
  • Should scope violations be logged as security events for monitoring?

0121 - Event Bus

  • Feature Name: Unified Event Bus
  • Start Date: 2026-04-04
  • Discussion: #121
  • Crates: daemon, core, runtime
  • Updates: 0080 (Cron)

Updated by 0203 (Client-Side Orchestration) (2026-08-15). “Non-blocking delegation” is removed with the daemon-side delegate hook: the background field returned task IDs nothing consumed. Client-side fan-out is synchronous. Detached work that outlives the client is still unsolved and belongs to a daemon jobs API.

Summary

A daemon-level event bus that routes named events to target agents via exact-match subscriptions. Agent completion is the first built-in event source. The bus also enables non-blocking delegation and ad-hoc worker agents.

Motivation

The daemon can trigger agents on a schedule (cron) and run agents on user request (protocol). But there’s no way for one agent’s completion to trigger another agent. The Signal pipeline (crabtalk/app#59) needs exactly this:

RSS fetch → Scout classifies → Crab enriches → client notification

Each stage produces a result that the next stage consumes. Without an event system, this requires the client to orchestrate the chain — polling, waiting, re-sending. The daemon should own this.

Separately, delegate blocks the parent agent until all tasks complete. For background research or parallel work, this is a limitation. If the daemon can route agent completion events, non-blocking delegation falls out for free.

Design

Event bus

An in-memory subscription table that matches events by exact source string and fires target agents with the event payload as message content.

# events.toml
[[subscription]]
id = 1
source = "agent:scout:done"
target_agent = "crab"
once = false

Follows the CronStore pattern: HashMap-backed, TOML-persisted, auto-incrementing IDs, atomic writes (tmp + rename). Survives runtime reloads.

Event sources

Events are namespaced strings. Two source types exist today:

SourceExampleEmitter
Agent completionagent:scout:doneDaemon, via on_agent_event hook
Externalrss:fetch, signal:classifiedClient or adapter, via PublishEvent

Agent completion events are emitted automatically when a conversation stream ends. The payload is the agent’s final text response.

External events are published via the PublishEvent protocol message — any client, adapter, or webhook handler can fire events into the bus.

Routing

Event arrives (via DaemonEvent::PublishEvent)
  → event loop calls EventBus::publish() inline (no spawn)
  → exact match source against subscription table
  → for each match: fire target agent via SendMsg (fire-and-forget)
  → if once: remove subscription, persist

Events always start new work. There is no injection into active conversations — that’s a separate concern (#117).

Fired agents receive the payload as message content with sender "event:{source}". This follows the established convention ("delegate:{id}", "cron") for non-user senders.

Protocol

Four new operations on the Server trait:

message SubscribeEventMsg {
  string source = 1;
  string target_agent = 2;
  bool once = 3;
}

message UnsubscribeEventMsg { uint64 id = 1; }
message ListSubscriptionsMsg {}
message PublishEventMsg { string source = 1; string payload = 2; }

Responses: SubscriptionInfo for subscribe, Pong for unsubscribe/publish, SubscriptionList for list.

DaemonEvent::PublishEvent

All publish paths route through a single DaemonEvent::PublishEvent variant in the central event loop. This avoids lock-ordering issues — the event bus mutex is only acquired inside the sequential event loop, never from the protocol handler or hook callbacks directly.

#![allow(unused)]
fn main() {
DaemonEvent::PublishEvent { source, payload } => {
    self.events.lock().await.publish(&source, &payload);
}
}

Non-blocking delegation

The delegate tool gains a background: bool field. When true:

  1. Tasks are spawned via the existing spawn_agent_task mechanism
  2. dispatch_delegate returns immediately with task IDs
  3. The parent agent continues working
  4. When each task completes, the daemon emits agent:{name}:done
  5. Event bus routes the completion to any matching subscriptions

No new mechanism — just the existing spawn infrastructure plus the event bus.

Worker pseudo-agent

A built-in worker agent registered at daemon startup alongside crab. Always available as a delegate target without pre-configuration:

  • Inherits the system agent’s thinking setting
  • Gets the full tool registry (no explicit filter)
  • Ephemeral — sessions are killed after task completion (existing behavior)
  • Always a valid delegate target (delegation is not scoped)

This eliminates the friction of configuring named agents for ad-hoc tasks like “read these files and summarize” or “search for X in the codebase.”

What this is NOT

  • Not a message broker. No durability, no exactly-once delivery, no dead letter queues. Fire-and-forget with best-effort delivery.
  • Not an orchestration DAG. No conditional routing, no fan-out/fan-in. Agents subscribe to events — that’s it.
  • Not a replacement for delegate. Delegation is synchronous and returns results inline. Events are asynchronous and deliver results out-of-band. background: true bridges the two.

Updates

0080 - Cron

The cron system continues to work as-is. Cron entries fire skills via the daemon event channel — this is unchanged. A future iteration may refactor cron as an event source adapter, emitting cron:{id}:fired events into the bus, but this is not in scope. The event bus is additive, not a cron replacement.

Alternatives

Agent completion triggers (no bus). A simpler design where completion of agent X directly triggers agent Y, without a general subscription mechanism. Rejected because the Signal pipeline needs external events (RSS fetch results) alongside agent completions — a bus handles both uniformly.

Glob matching on source patterns. The RFC originally proposed wildcard subscriptions like "agent:*:done". Rejected for v1 — exact match covers all current use cases. Glob matching can be added when a real consumer needs it.

Template interpolation. The RFC originally proposed {{payload}} interpolation in a prompt_template field. Rejected — agents are the template engine. The payload goes in as-is; the agent’s instructions handle interpretation.

Unresolved Questions

  • Should there be a max subscription count?
  • Should the bus detect infinite loops (agent A triggers B triggers A)? Currently fire-and-forget prevents stack overflow but allows unbounded chains of spawned tasks.

0135 - Agent-First Protocol

Summary

Replace session-centric protocol addressing with agent-centric addressing. Users talk to agents, not sessions. Introduce guest turns for multi-agent conversations and compaction archives as the agent’s long-term memory.

Motivation

The original protocol was session-centric: clients managed session IDs to kill, reply, compact, and route messages. This leaked an implementation detail (the session ID) into every client and forced multi-agent interaction into either permanent agent switching or invisible delegation.

Problems with the session model:

  1. Session IDs leak everywhere. Every client (CLI, Telegram, WeChat, IDE) must track session IDs to route replies, kill conversations, and handle ask_user prompts. If a client loses the ID, the conversation is orphaned.

  2. Multi-agent is invisible. When agent A delegates to agent B, the result comes back as a tool result string. The user hears A’s summary of B’s answer, never B’s actual voice. There’s no multi-agent conversation.

  3. Session ≠ conversation. “Session” conflated device connections (CWD, transport state) with agent memory (message history, compaction). These are different lifecycles — connections are ephemeral, conversations persist.

Design

Core model

Each agent has one continuous conversation per user. Conversations are keyed by (agent, sender) — no session IDs in the protocol.

Client: StreamMsg { agent: "crab", content: "hello", sender: "user" }
Daemon: resolves (crab, user) → internal conversation, runs agent, streams response

Conversation vs session

SessionConversation
WhatDevice ↔ daemon connectionAgent’s memory with a user
Keyconnection/device ID(agent, sender)
Lifetimeephemeralpersistent
StateCWD, transportmessages, title, JSONL, archives

Sessions are daemon-internal. Conversations are the protocol-visible abstraction.

Protocol changes

Client messages address conversations by (agent, sender):

message StreamMsg {
  string agent = 1;
  string content = 2;
  optional string sender = 4;
  optional string cwd = 5;
  optional string guest = 6;  // guest turn
}

message KillMsg {
  string agent = 1;
  string sender = 2;
}

message ReplyToAsk {
  string agent = 1;
  string sender = 2;
  string content = 3;
}

message CompactMsg {
  string agent = 1;
  string sender = 2;
}

Removed from the protocol: session (u64 ID), new_chat, resume_file.

Server responses no longer include session IDs:

message StreamStart {
  string agent = 1;  // no session field
}

Guest turns

The guest field on StreamMsg enables multi-agent conversations. When set, the daemon runs the guest agent against the primary agent’s conversation history — text-only, no tool dispatch.

Flow:

  1. Client sends StreamMsg { agent: "twin", content: "question", guest: "crab" }
  2. Daemon finds twin’s conversation
  3. Adds user message to twin’s history
  4. Injects guest framing (auto-injected system message)
  5. Runs crab against twin’s history with crab’s system prompt (no tools)
  6. Tags response with agent: "crab"
  7. Appends to twin’s history

The guest’s response appears as a first-class message in the conversation, attributed to the guest. No delegation, no tool results, no paraphrasing.

Bidirectional framing

Both guest and primary need context about multi-agent conversation:

  • Guest framing (injected when a guest runs): “You are joining a conversation as a guest. Messages wrapped in <from agent="..."> tags are from other agents.”
  • Primary framing (injected when the primary runs and guest messages exist in history): “Messages wrapped in <from agent="..."> tags are from guest agents. Continue responding as yourself.”

Both are auto_injected — stripped before each run, re-injected fresh. Zero accumulation.

Message attribution

The Message struct gains an agent field:

#![allow(unused)]
fn main() {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub agent: String,
}

Empty = the conversation’s primary agent. Non-empty = a guest. When building LLM requests, assistant messages with non-empty agent are prefixed with <from agent="..."> XML tags so every agent can distinguish speakers.

Message::with_agent_tag() handles the prefixing — one function, used by both build_request and guest_stream_to.

Compaction as memory

Compaction markers become archive boundaries. Each compact marker stores a title (first sentence of the summary, max 60 chars) and a timestamp:

{"compact":"Summary of pricing discussion...","title":"Pricing analysis for solo dev tools.","archived_at":"2026-04-03T10:00:00Z"}

The conversation is continuous — compaction doesn’t create a new conversation, it archives a segment of the existing one. Archived segments are browsable via Conversation::load_archives() and available to the recall tool as long-term memory.

Crab's memory:
├── [active] Current conversation
├── "Pricing analysis for solo dev tools." — 2 days ago
├── "Auth module refactor plan." — 5 days ago
└── "HN competitor signal analysis." — last week

What dies

  • Session IDs in the protocol — replaced by (agent, sender)
  • new_chat — the conversation is continuous, compaction handles the window
  • resume_file — one conversation per (agent, user), always active
  • Client-side @mention logic (0078) — guest turns handle it daemon-side
  • Session forking — agents are the abstraction, not sessions

Supersedes

0064 - Session

The session model is replaced by conversations. The JSONL file format is preserved (backward compatible with added title and archived_at fields on compact markers, and agent field on messages). The Session struct is renamed to Conversation. Session IDs are removed from the protocol.

0078 - Compact Session

The compact-then-handoff pattern for @mentions is replaced by guest turns. The daemon handles multi-agent conversation natively — no client-side compact logic needed.

Updates

0018 - Protocol

Session-addressed messages are replaced with (agent, sender) addressing. StreamMsg and SendMsg gain a guest field. SessionInfo becomes ActiveConversationInfo. See protocol changes section above.

0038 - Memory

Compaction archives become the primary long-term memory mechanism. The recall tool searches across archived segments. See #101 (revised) for the pluggable memory provider aligned with this model.

0184 - crabup

Note (2026-08-22). This RFC made service management the point: crabup owned launchd/systemd/schtasks lifecycle for every crabtalk binary, and cargo install was the part it merely wrapped. That half is gone — crates/command was deleted with the harness work, no platform unit is written, and there are no start/stop/ps/logs verbs. So is the resolution table: crabup installs one product and names its crates as a constant. What survives is distribution and the install layout, which is what the text below now describes. The verb history (pull/rm shipping as install/uninstall, the add/remove pair for harness services) is settled and no longer worth carrying: harnesses are ELF images with no process, per 0205.

Summary

crabup is how crabtalk reaches a machine and stays current. It is a wrapper over cargo install: crates.io is the registry, workspace-version inheritance is the version coordination, and cargo install is already an upgrade when a newer version exists and a no-op when it is not. Prebuilt binaries are a second backend for the same verbs, and wait on a release job that does not exist yet.

It also owns the install layout. ~/.crabtalk is defined here, the way rustup defines ~/.rustup, and every other crate reads the paths from crabup::dirs instead of re-deriving them. That is the half the workspace depends on, and it is why the commands sit behind a cmd feature: a crate that wants CONFIG_DIR should not build a CLI to get it.

Command surface

crabup install [--version X | --nightly] [--features a,b] [--no-default-features]
crabup update                 # the same command
crabup uninstall
crabup list

No target name. crabup installs crabtalk, which is more than one crate — today crabtalk-agent, whose binary is crabtalkd, and crabtalk-cli, whose binary is crabtalk, when it exists. Package and binary differ on purpose: the package name namespaces a registry, the binary name is what someone types. They go on and come off together, because they speak one protobuf protocol to each other and a machine holding two versions of it is a wire mismatch nobody asked for. cydonia and a berm instance join later.

What each verb does

install, and update, which is an alias rather than a second command: cargo install each crate, passing --version when pinned. Installing a version already present is a no-op, and installing over an older one is the upgrade — there is nothing left for a separate verb to do.

--nightly switches the source to --git on the repository’s dev branch, with --locked so the build matches the lockfile that branch tested against. It conflicts with --version: a branch has one tip, and pinning it is what --version against crates.io is for. This is the same pair of backends the prebuilt path will slot into — a flag choosing where the bits come from, behind unchanged verbs.

uninstall runs cargo uninstall for each.

list reads ~/.cargo/.crates.toml and prints each crate with its version. There is no parallel state file: if cargo’s record is wrong then cargo is wrong, and crabup being wrong with it is correct.

Removed

Service management. No plist, no unit file, no supervision. The daemon runs in the foreground under whatever started it — a terminal today, a client process next. Distribution and lifecycle were coupled here because the daemon used to install itself; with the daemon out of that business, lifecycle belongs to whoever wants the process alive, not to the installer.

Pass-through installs. crabup install <any-crate> left with the table it resolved against. cargo install is that command.

The GitHub download path. Written and removed on 2026-08-22 — it fetched {bin}-{version}-{platform}.tar.gz from the releases page, which no job builds, so every install fell through to cargo anyway. It comes back with the release job, behind the same verbs.

Harness images. ELF files, not binaries with a process (0205), installed today by make harness into ~/.crabtalk/harnesses. Something should install them; it is not this surface yet.

Unresolved Questions

  • What uninstall removes. Binary only today — ~/.crabtalk and its store survive. That is the safe default, not a decided one.

0189 - Policy at the Edge

Updated by 0203 (Client-Side Orchestration) (2026-08-15). The peer-agents <agents> block this RFC moved into on_build_agent now leaves the daemon entirely — naming delegation targets is policy, and it belongs with the client that offers the tool.

Summary

Mechanism belongs in the daemon; policy belongs at the edge. The daemon stops making decisions on the user’s behalf — it no longer auto-compacts on a token-count heuristic, no longer spawns title-generation calls in the background, no longer BM25-searches memory and injects synthetic <recall> user turns. Each of these is now an explicit RPC the client calls when (and if) it wants the behavior. A new AgentEvent::ContextUsage { usage } carries real per-step token counts so clients can pick their own pressure threshold. The Hook::on_before_run lifecycle method is removed.

Motivation

Three independent features had drifted toward the same anti-pattern: the daemon making policy decisions using its own heuristics, then mutating conversation state on the user’s behalf without being asked. RFC 0000 codified auto-compaction at a chars/4-derived threshold. RFC 0038 (then 0150) codified auto-recall as a per-turn before-run injection. The runtime grew a quiet spawn_title_generation call inside finalize_run. Each was useful in isolation. Together they shaped a daemon that thought it knew best.

The cost of that posture:

  • Bad heuristics. Token estimation as chars/4 is wrong for code, JSON tool outputs, and non-English prose. The threshold either trips early (destroying live context with an unwanted summary) or trips late (the request fails anyway). The daemon doesn’t have the inputs — model identity, real token counts, user intent — to pick a threshold. Clients do.
  • Synthetic events. Auto-compaction yielded AgentEvent::Compact followed by hand-forged TextStart/TextDelta/TextEnd events containing the literal string [context compacted]. Auto-recall injected <recall>...</recall> user turns flagged auto_injected: true. Both lied to the event stream — the model didn’t say those things, the daemon did. Downstream consumers had to filter them out.
  • Wasted tokens, opaque costs. Auto-titling spent an LLM call after every conversation that crossed two history entries, behind the user’s back. Auto-recall paid retrieval cost on every turn whether or not the model would have asked.
  • Race with the explicit API. All three behaviors had explicit-API counterparts (compact_conversation, the recall tool, a clearly-named title RPC if the client wanted one). The daemon was racing the client to call its own API.

RFC 0185 already drew the right line for sessions: “the runtime’s job is to provide mechanical primitives. UX decisions belong one layer up in the client.” This RFC carries that all the way through.

Design

Principle

Mechanism in the daemon, policy at the edge. Concretely:

  • Mechanism is what only the daemon can do: own conversation state, own storage, own the LLM connection, own MCP child processes, run summarization, write archives. These are inherently centralized.
  • Policy is everything else: when to compact, when to title, what to prepend to a user message, what counts as context pressure. These need information the daemon doesn’t have (which model, which UI, which user, which tradeoff matters today). Policy lives in the client — TUI, telegram, web app, headless automation — and is composed from primitives the daemon exposes.

Where this leaves heuristics: the daemon doesn’t run them. If the daemon would need to estimate something to decide, the answer is “don’t decide — surface the data and let the client decide.”

What was removed

Auto-compaction. The block in Agent::run that called self.compact(history) when estimate_tokens(history) > threshold is gone. The synthetic Compact/TextStart/TextDelta(\"[context compacted]\")/TextEnd events are gone. AgentConfig::compact_threshold is gone (silently dropped from existing TOML via serde default). HistoryEntry::estimate_tokens and the chars/4 heuristic are gone.

Auto-titling. Runtime::spawn_title_generation and its finalize_run call site are gone. The title field on Conversation and ConversationMeta stays — existing data is still valid, the daemon just doesn’t generate fresh titles on its own.

Auto-recall. Memory::before_run (the BM25-search-and-inject helper) is gone. MemoryHook::on_before_run is gone. The recall tool is unchanged — model-driven recall continues to work.

Hook::on_before_run. The trait method is removed. OsHook previously used it to inject <environment>working_directory: ...</environment> per turn — that goes too. Bash dispatch still resolves the effective cwd at tool-call time, so commands run in the right directory; the model just doesn’t get a synthetic turn telling it where it is. Clients that want the model to see the cwd put it in their own user message (they supplied it via req.cwd in the first place). The peer-agents <agents> block that DaemonHook::on_before_run injected for delegation moves to DaemonHook::on_build_agent so it lands in the system prompt at agent-build time — registry mutations are visible after the next agent rebuild.

What was added

AgentEvent::ContextUsage { usage: Usage }. Emitted once per LLM call when the provider reports non-zero usage. Carries real prompt_tokens, completion_tokens, total_tokens, plus optional cache-hit/miss and reasoning counts. The corresponding wire event is ContextUsageEvent { usage: TokenUsage }. Clients track these and decide for themselves when to call compact_conversation.

Real compact_conversation. The runtime method previously returned the summary string and silently dropped the persistence work. It now does all four steps in order: summarize → write archive entry → write session compact marker → replace history with a single user message carrying the summary. Atomic from the client’s perspective.

Reference: explicit replacements

Each removed behavior maps to an existing or planned API:

RemovedExplicit replacement
Auto-compactioncompact_conversation(agent, sender) RPC, gated on client-tracked ContextUsage events
Auto-titlingA future generate_title(conversation_id) RPC; until then, clients can run their own summarization or leave titles blank
Auto-recallThe recall tool (model-driven); or a client-side recall + send composition before the user’s message

The opt-in client-side helpers for each of these are tracked in #188 as SDK sugars — a few dozen lines on top of the daemon client.

Migration

  • New conversations have empty title until a client asks for one. Existing titles on disk are unaffected.
  • The recall tool still works. Clients that previously relied on silent <recall> injection need to either let the model call recall itself (the intended path) or compose recall + send client-side.
  • No auto-compact. Clients should subscribe to ContextUsage events and call compact_conversation when their threshold trips. The model returns an explicit error if context is exceeded — the daemon no longer guesses.
  • compact_threshold in agent TOML is silently dropped via serde default. No errors, just ignored.

Alternatives considered

Keep auto-compact as a safety net. RFC 0185 took this position: “automatic compaction on overflow as a safety net” because clients can’t see overflow coming. Rejected here because the daemon can’t reliably detect overflow either — chars/4 is the wrong tool, and the model itself returns a clear error when context is exceeded. A bad safety net is worse than none, because clients build trust in it and stop watching.

Threshold-gated ContextPressure event. Emit only when over some threshold. Rejected because it recreates the policy problem in a smaller form — the daemon still picks a number, and is still wrong for whichever model and use case it didn’t anticipate. Always-emit ContextUsage lets clients pick.

Move policy to per-agent config knobs. “Auto-compact off by default; opt in via compact_threshold.” Rejected because the per-agent config is set by the client at create-time anyway — moving the decision a step earlier doesn’t change who decides, just makes the decision harder to update. A per-call decision (the client picks each turn) is more honest.

Out of scope

Two daemon-side per-turn injections in prepare_history survive this RFC: the <instructions> block from Crab.md discovery and the guest-agent-framing prose (“Messages wrapped in <from agent=\"...\">…”). Same anti-pattern, deferred to a separate cleanup so this RFC stays focused.

Wire-protocol changes are limited to the new ContextUsageEvent and reservation of AgentInfo.compact_threshold (field 10). No breaking renumbering, no new RPCs.

0193 - Agent-Owned MCP

Updated by 0204 (MCP Peer Lifetime) (2026-08-15). The fingerprint gains auth, and registering an MCP no longer spawns a process — a declaration and a peer now have separate lifetimes.

Updated by 0207 (Store) (2026-08-18). FsStorage and MemStorage no longer exist; a store implements KVStorage and gets the rest. The decision recorded here — that MCP configs live on the agent rather than in their own storage methods — is unchanged and is why no MCP keys appear in the keyspace.

Summary

Agents own their MCP servers by value, not by name reference into a daemon-global registry. AgentConfig.mcps becomes Vec<McpServerConfig> — every agent carries the full configuration of every MCP it uses. The daemon’s job shrinks to “spawn what agents declare, dedup identical processes, route tool calls per agent.” Storage::{list,upsert,delete}_mcp and crabtalkd mcp go away. Forking an agent now means copying one config; the new owner gets a self-contained, runnable artifact.

Motivation

The current model treats MCPs as a daemon-level resource that agents reference by name. That made sense when crabtalk was a single-user CLI managing a fixed fleet of tools. It doesn’t fit where the runtime is going.

Forkability is broken. RFC 0135 framed agents as the unit users see and share — sessions are plumbing, agents are the artifact. Cloud workflows extend that: an agent should be a forkable thing, like a GitHub repo. Today, forking an agent’s TOML doesn’t fork its MCPs; the fork lands on a daemon that may or may not have a server registered under the same name, with the same args, with the same env. The agent reference is a dangling pointer until someone manually re-registers the missing pieces.

Namespace pollution is artificial. Two agents that want the same logical MCP with different env (e.g., one read-only token, one admin token) must register two differently-named entries in a global flat namespace. The bridge’s tool_cache: BTreeMap<String, Tool> then logs-and-skips conflicts when both expose web_search. None of that pollution is intrinsic to MCP; it’s a consequence of the registry shape.

The allowlist is a workaround for ownership. AgentConfig.mcps: Vec<String> (RFC 0082) gates which global entries an agent may dispatch to. It exists because the registry is shared. If agents own their MCPs, allowlists become tautological — the agent only dispatches to what it declared.

The cloud target makes this acute. Cloud will import crabtalk as a library and host one agent per tenant (or per agent instance). A daemon-global registry on a multi-tenant host either leaks configurations across tenants or forces the cloud layer to maintain its own per-tenant overlay on top of the registry. Either way the global registry is wrong — the right shape is “agent has its MCPs,” and the cloud’s secret/canonical layer can compose forkable templates above that.

Design

Data model

#![allow(unused)]
fn main() {
struct AgentConfig {
    // …
    mcps: Vec<McpServerConfig>,  // was Vec<String>
}
}

Embedded by value. No enum wrapper, no separate “decl” type. The agent’s TOML carries every field of every MCP it depends on.

Storage loses list_mcps, upsert_mcp, delete_mcp. The protocol RPCs ListMcps, UpsertMcp, DeleteMcp stay — they shift meaning from “manage the global registry” to “list MCPs declared by any registered agent” / “modify an agent’s MCPs in place” / “remove an MCP from an agent’s config.” Implemented by reading and writing through the agent’s config rather than a separate table.

Daemon-side dedup

The daemon never spawns the same MCP twice. Two agents declaring command="github-mcp", args=[...], env={TOKEN: "abc"} share one peer process. Different args or env → separate processes. Identity is structural, not by name.

McpHandler keys peers by fingerprint — a stable hash of (command, args, env, url). The state map becomes BTreeMap<Fingerprint, McpServerEntry> where each entry refcounts the agents that declared it. register_for_agent(agent, cfg) increments the refcount, spawning if first; unregister_for_agent(agent, fingerprint) decrements, tearing down at zero.

The lifecycle event broadcast from RFC 0190 (PR #192) still applies: Connecting / Connected / Failed / Disconnected are emitted per fingerprint, not per name. The event payload identifies the server by fingerprint plus the set of agents that own a reference to it.

Per-agent tool namespace

The bridge stops sharing a flat tool_cache. Two agents declaring different MCPs that both expose a web_search tool no longer collide — the dispatcher resolves (agent, tool_name) to the right peer through the agent’s declared fingerprints.

Concretely: McpBridge keeps the per-fingerprint peer map but drops the global tool cache. Tool lookup walks the agent’s fingerprints in declaration order and returns the first match. McpHook::dispatch already has the agent context; it now uses the agent’s declared MCPs directly instead of consulting an AgentScope.mcps allowlist.

Lifecycle interactions

  • Agent create / update. Runtime::create_agent and update_agent walk the config’s mcps list, calling McpHandler::register_for_agent(agent, cfg) for each. New fingerprints spawn; existing fingerprints just bump the refcount.
  • Agent delete. Walks the agent’s mcps, calls unregister_for_agent for each. Peers with refcount=0 are torn down. Disconnected events fire.
  • Agent rename. Refcounts move from old_name to new_name. No spawn/teardown.
  • Daemon startup. Storage rebuilds agents one by one; each register_for_agent call walks the same dedup path. No special “load global MCPs” phase.
  • Daemon reload. Already rebuilds agents (RFC 0189-era refactor). Same path. New configs trigger spawns; removed fingerprints trigger teardowns.

Where secrets are not

The daemon stores literal McpServerConfig values. There is no placeholder syntax, no resolver trait, no interpolation in this codebase. If a value looks like ${TAVILY_KEY}, the daemon spawns a process with that literal string in the environment.

The “canonical with placeholders / materialized with values” split lives in whatever sits above the daemon. Cloud’s control plane holds canonical agent configs (with ${TAVILY_KEY}), resolves against the tenant’s vault, and writes the resolved config to the daemon-as-library it owns for that tenant. Forks copy the canonical, never the resolved.

This keeps the forkability invariant — shareable artifacts carry structure, not values — while keeping the daemon secret-unaware.

Migration

AgentConfig.mcps is a breaking field type change (Vec<String> → Vec<McpServerConfig>). Existing configs on disk need a one-shot migration:

  1. On daemon startup, if any agent’s mcps is Vec<String> (detected via serde), look each name up in the existing mcps.toml (or whatever Storage held the global registry), inline the McpServerConfig, and rewrite the agent’s TOML.
  2. After every agent has been migrated, delete the global mcps.toml.

The migration runs once. After the first startup on the new code, configs are uniformly the new shape; the migration code path is dead and gets removed in a follow-up cleanup commit.

Storage::list_mcps / upsert_mcp / delete_mcp are removed from the trait. Implementations — FsStorage, MemStorage — drop the corresponding files/fields. The protocol RPCs ListMcps / UpsertMcp / DeleteMcp stay on the wire; their handlers are rewritten to operate on agent configs.

AgentScope.mcps (RFC 0082) is removed. The scoping struct still gates tools and skills; MCP scoping is now intrinsic to the agent’s declaration.

Alternatives considered

Keep the global registry, add per-agent overrides. Allow AgentConfig.mcps to carry inline overrides on top of name references. Rejected because it doubles the configuration surface — every consumer has to handle “which wins, the override or the registry?” — without solving forkability. Forking an agent still depends on the destination daemon having the right names registered.

SecretResolver trait in this repo. Earlier draft. Cut because the daemon can stay secret-unaware: cloud handles canonical-vs-resolved at its control plane and only writes resolved configs into the daemon. Adding a trait here for a default that just reads env vars is complexity for a problem we don’t have.

Generic on Daemon for the resolver. Even if a resolver lived in this repo, adding a second type parameter to Daemon<P> compounds complexity per the no-generics-for-future-use rule. Not worth it for a hypothetical hook.

Package-provided MCPs as agent templates. Package install/uninstall lives in crabup, not the daemon, so this collapses. Future package-like artifacts compose at the agent level rather than at a separate MCP-registry level.

Out of scope

  • Secret resolution, vaulting, or ${VAR} interpolation. Cloud’s problem, not the daemon’s.
  • Auto-restart behavior for failed peers. Lifecycle events from PR #192 surface failures; whether a client retries is a client decision.
  • Discovery of port-file MCPs. Today McpHandler auto-connects services that drop a *.port file under ~/.crabtalk/run/. That mechanism continues to work, but discovered servers now register against a synthetic per-process “discovery agent” (or are exposed only on the daemon-internal dispatch path) — the exact shape is a follow-up.
  • Package MCPs. Package install lives in crabup; no daemon-side migration needed.

0204 - MCP Peer Lifetime

  • Feature Name: MCP Peer Lifetime
  • Start Date: 2026-08-15
  • Discussion: TBD
  • Crates: mcp, core, crabtalk, tui
  • Updates: 0193 (Agent-Owned MCP)

Summary

An MCP peer is a process, and this RFC gives it a lifetime of its own rather than borrowing the declaration’s. Credentials join the dedup fingerprint, so a peer is never shared across a trust boundary. A peer that stops answering now says so instead of reporting Connected forever. ReconnectMcp brings one back on request. And a peer starts on an agent’s first MCP tool call and stops once it goes idle, instead of existing for as long as some agent has the config in its TOML.

Motivation

RFC 0193 made MCPs agent-owned and deduped identical configs down to one process. Both decisions hold. What did not survive contact with a multi-tenant host is the assumption underneath them: that a declaration and a process are the same thing, and that structural identity is (command, args, env, url).

Credentials were not part of identity. fingerprint hashed env precisely because it carries a stdio server’s secrets — but auth, the HTTP transport’s equivalent, was left out. Two agents naming the same URL with different bearer tokens hashed identically, so the second refcount-bumped onto the first one’s peer and its tool calls went out under the first agent’s header. On a single-user install that is invisible. On a shared host it is a credential leak, and it is the one config that dedup must never collapse.

Peers were tied to registration. register_for_agent spawned on first reference and only the last unregister_for_agent tore down. For a CLI with a handful of agents that is fine — everything is warm and nothing is wasted. Host ten thousand tenants and it means a child process for every MCP any of them ever mentioned, alive from boot, whether or not a model ever called one. Dedup does not rescue this: the interesting servers are per-tenant credentialed, and with credentials now correctly part of identity, those are exactly the configs that cannot share a peer. The process count scales with tenants, not with distinct tools.

Failure was invisible. Nothing wrote back to peer state after the initial connect. An expired token or a dead child surfaced as an error string handed to the model; states() kept reporting Connected and McpEventKind::Failed never fired again. A client watching the lifecycle stream was blind to the single condition it would want to act on, which also made “let the client decide whether to retry” — 0193’s stated position — unimplementable in practice.

Design

Credentials are identity

fingerprint hashes (command, args, env, url, auth). One peer sends one Authorization header on behalf of every agent sharing it, so agents holding different tokens get different peers.

This has a second effect worth naming: rotating a token is now a real reconnect through the existing surface. A new token is a new fingerprint, so UpsertMcp with fresh credentials tears the old peer down and stands a new one up. Before, the rotated config hashed to the same fingerprint, took the refcount fast path, and left the peer running with the expired header while the daemon reported success.

It also exposed a latent leak. register_for_agent already handled a claim moving between fingerprints, but it only removed the ref — a peer whose ref set went empty stayed in the map, unreachable from by_owner, so unregister_for_agent could never find it and the process lived until daemon shutdown. That path was rare when only a command or env edit reached it. Putting auth in the fingerprint makes every token rotation take it, which would have turned a rare leak into a routine one. The claim move now mirrors unregister_for_agent: last ref out, peer torn down.

Declaration and process have separate lifetimes

This is the data-model change the rest rests on. A PeerEntry is a declaration. It appears when an agent registers the config, survives eviction, and is removed only when the last owner unregisters. Whether a process is currently running behind it is state.status, and nothing else.

  • Registration records. register_for_agent inserts or refcounts the declaration and returns. It spawns nothing, which is what removes the boot-time process fleet.
  • Dispatch spawns. dispatch_mcp calls ensure_connected before anything else. The mcp meta-tool is the only door a model has — listing and calling both arrive here — so one call covers both. The ordering is load-bearing: allowed() reads the tool list a peer only reports once connected, so connecting second would tell an agent it has no tools it could ever reach.
  • The reaper stops. A background task ticks at idle_timeout / 4, so a peer outlives its deadline by at most a quarter of it. It holds a Weak, so it ends with the handler rather than keeping it alive. Failed peers are reaped too — the transport broke, but the process is still running and still costs a slot.

Connects run concurrently across an agent’s MCPs and are serialised per peer by a gate on the entry, so two agents reaching a shared peer wait on one spawn instead of racing two processes under the same id.

Failed peers are not retried by ensure_connected. A peer with a wrong command would otherwise burn a connect timeout on every turn, forever. It becomes eligible again once the reaper ages it back to Disconnected, which bounds the retry rate to the idle timeout with no backoff machinery, and ReconnectMcp exists to retry sooner on demand.

UpsertMcp still connects eagerly. Registration is mechanical and lazy, but a human who just typed a config should learn about a bad command or a stale token there and then, not inside some later tool call. So the daemon-side handler calls ensure_connected explicitly after the write. Mechanism in the handler, policy at the edge — RFC 0189’s split, applied here.

Health is state, not a return value

McpBridge::call flattened five distinct failures into one Err(String), and two of them mean opposite things: mcp tool error is the far side answering with a rejection, so the connection is fine, while mcp call failed is the call never landing. Marking a peer failed on the flattened error would have torn down a healthy peer every time a model passed a bad argument.

So the return type is Result<String, CallError> with Rejected and Transport, rendering through Display to the exact strings the model already saw. Only Transport touches peer state.

The call itself moved onto McpHandler. The one place that learns a peer is dead had no access to the state clients read; now the bridge owns connections and the handler owns observable state. allowed() returns Fingerprint rather than a hex string, so the typed id runs to the edge and stringifies once at the bridge boundary — the handler no longer has to parse its own encoding back to find a peer.

Marking a peer failed preserves its tool list. The tools describe what the peer exports, not whether it is reachable; clearing them makes the next call fail with tool not available, which disguises a dead connection as a scoping error. Eviction clears them, because by then nothing is running.

Reconnect

ReconnectMcpMsg { name, agent } joins the ClientMessage oneof at tag 54, mirroring DeleteMcpMsg field for field. It answers with McpInfo rather than Pong, the way UpsertMcp does, so a caller gets post-reconnect status and any error in one round trip instead of following up with ListMcps.

Reconnecting is per-peer, not per-claim: a process shared by several agents comes back once for all of them, and every owner sees the lifecycle events under its own name for that MCP.

The config comes from the peer, not the caller. PeerEntry stores the McpServerConfig it was spawned from, env overlay already applied. This matters because McpHook applies a daemon-wide overlay at registration, so the tracked fingerprint is of the effective config — reconnecting from the agent’s stored config would compute a different fingerprint and strand the peer under an id that no longer describes it. Storing what was actually run removes the whole class of drift, and leaves the wire message carrying nothing but identity.

Changing a config is explicitly not this operation. That is UpsertMcp, which mints a new fingerprint and therefore a new peer.

Configuration

[mcp]
idle_timeout = 1800

Seconds a peer may sit unused before the reaper stops it. Zero disables eviction and restores 0193’s behaviour of peers living until their last agent unregisters. A deployment picks this because the right answer differs between a laptop, where warm peers cost nothing that matters, and a shared host, where process count is the binding constraint.

Updates

0193 - Agent-Owned MCP

The fingerprint tuple gains auth — 0193 specifies (command, args, env, url).

“register_for_agent(agent, cfg) increments the refcount, spawning if first” no longer holds; it records only. The Lifecycle interactions section reads the same way throughout — agent create, update, daemon startup, and daemon reload all describe new fingerprints spawning. They now register declarations, and the first dispatch spawns. Agent delete and rename are unchanged.

Out of scope ruled that “whether a client retries is a client decision.” That intent survives and is finally actionable: the daemon still never auto-restarts, but a failed peer is now visible on the lifecycle stream and in ListMcps, and ReconnectMcp gives the client something to call. The reaper ages Failed back to Disconnected, which makes the following dispatch a natural retry — a bound on retry frequency, not an auto-restart policy.

The Migration section’s one-shot inlining of the global [mcps] registry has been removed, as that section itself anticipated (“the migration code path is dead and gets removed in a follow-up cleanup commit”).

Alternatives

Sweep for idle peers during dispatch instead of running a reaper. No background task, no tick interval. Rejected because it inverts the case that matters: a host with thousands of idle tenants is precisely one where nothing dispatches, so nothing would ever sweep. Eviction has to be driven by time passing, not by traffic.

Retry failed peers automatically with backoff. Rejected as machinery for a bound the design already has. The idle timeout is a retry interval for free, and it is a number the operator already chose.

Per-agent idle timeout. Rejected because a peer is shared. Two agents with different timeouts on one process leaves no honest answer to whose wins, and the tie-break would be invisible to both.

Keep the config out of PeerEntry and pass it to reconnect. Rejected: the effective config is the handler’s own product, not the caller’s, and reproducing it at the call site means duplicating the env-overlay rule wherever a reconnect is triggered.

Unresolved Questions

  • None of this has been exercised against a live daemon. The reaper’s timing, the per-peer gate under real concurrency, and the Failed → Disconnected → retry cycle are reasoned through rather than observed.
  • The first call after an eviction pays a connect, bounded by the existing 30s MCP_CONNECT_TIMEOUT. What that actually costs a conversation has not been measured, and it is the number that would justify moving idle_timeout’s default.
  • McpHook’s env_overlay is daemon-wide and backfills any key a tenant’s MCP did not set. Harmless for one user; on a shared host it means a daemon-level secret can reach a tenant process. Untouched here.

0205 - Berm

Updated (2026-08-17). Three changes since this landed. Images are keyed by a content digest — the ELF, its Grants, the capability Scope, and the granted hosts — rather than by (agent, harness), so identical declarations share one image and a rename is not an event. An http capability exists, bounded by a hosts allowlist on the declaration exactly as root bounds fs; it lives in crates/berm rather than the engine because hyper needs a reactor the sandbox deliberately lacks. And .berm.abi carries a usage string — what the tools are for — read off the ELF without running it and injected only into agents that declared the harness.

Updated (2026-08-22). The sandbox left, which this RFC said it would: berm is crabtalk/berm and reaches this repository from crates.io as berm and berm-lang. Every berm/* path below names a directory that is no longer here, and the layout it describes is upstream’s to keep. What stays is crates/berm — crabtalk’s side, unchanged.

Updated (2026-08-24). Two things this RFC designed are gone, and one it ruled out has landed. The per-harness allowlist over message types — protocol:read, protocol:sessions, one capability number carrying the whole surface — was removed: the runtime is reached through one capability per operation, and a capability the declaration did not bound is absent from the linker rather than refused on decode. The word grant went with it; the argument bounding a capability is the whole of it, and there is no list beside it. And “Can a harness call another deployed harness?” is answered yes: berm.call resolves a name per call against the declaring agent’s own resolution, which is why the session is now part of an image’s digest.

Updated by 0207 (Store) (2026-08-18). The crates/hooks paths cited below are gone — hooks were deleted as a crate, the internal Hook seam this RFC scoped out is now Harness, and its lifecycle methods were renamed (0207 supersedes 0075). Harness images have a home in the store as image/{digest}, but berm still reads them from HARNESSES_DIR on the filesystem; wiring it to the Harnesses interface is open work.

Summary

A harness is code the daemon schedules: one hash-pinned RV64IMAC ELF, compiled and run in-process by rvtime, confined to its own address space, reaching the world only through numbered host calls it was explicitly granted. A harness never runs of its own accord — the daemon decides when it runs, and while running it may call back in.

berm is what runs one. It is the sandbox rather than the agent platform: it loads the ELF, grants capabilities, and invokes, and it knows nothing about agents or the protocol. Crabtalk is one thing that embeds it, and deliberately not the only one — see Layout.

What it calls back into is the protocol. ClientMessage is already the daemon’s complete, versioned, externally-facing API, so it is the vocabulary for everything a harness does to the runtime: send to an agent, subscribe to an event, schedule a wake. One capability number carries the whole surface, with a per-harness allowlist over message types.

Harnesses replace stdio-spawned MCP servers as the way we distribute extensions. harness/search becomes one. harness/cron is deleted outright, because scheduling stops being a service and becomes the harness execution model: every harness invocation — tool call, timed wake, subscribed event — goes through one queue, and a cron entry is the case where the trigger is a time.

Two things follow that reach past extension code. Where a harness runs is a deployment property rather than a design one, which dissolves the “client tool” category: OS capabilities become a harness placed on whichever machine holds the files, and a client tool narrows to one whose result requires a human. And a harness is a third home for policy — not daemon-core, not client — which is where delegation belongs once sub-agents can reach tools without a client attached.

Motivation

Spawning a binary is not an extension mechanism. Today an agent declares an MCP server (RFC 0193) and the daemon runs the named command as a child process with the daemon’s own privileges: full filesystem, full network, full environment. Nothing about that grant is declared, inspectable, or revocable. The tool the model sees is search_web; the thing the operating system sees is an unbounded process. For code we wrote that is a manageable risk. For code a third party wrote it is the whole risk, and it is the reason there is no answer today to “can I install someone else’s tool?”

Distribution multiplies the problem. A native binary needs a build per platform — apps/crabup/src/github.rs:15-19 enumerates five — so a third-party tool author must produce and maintain five artifacts before anyone can install one. Most will not, and the ones who do become five chances for the artifact to differ from the source.

The word is already in the repo, meaning two different things. apps/crabup/src/registry.rs:6 defines harness/ as “the services you attach to a running system,” installed with crabup add. Two entries carry that label. crabtalk-search answers requests and has no state of its own between them. crabtalk-cron owns a timer, wakes itself, and calls the daemon over the SDK socket. These are not the same kind of thing, and calling both a harness has cost us a definition we could reason with.

The runtime exists and was built for this. rvtime compiles a statically linked RV64IMAC ELF to native code with Cranelift, caches the generated code on disk keyed by a hash of the CLIF function plus ISA settings, and lets the host register numbered call-backs. Its own cache module is documented for the case where “a daemon may compile the same plugin from several processes at once.” It is published as rvtime 0.0.1 and we own it.

Design

Definition

A harness never runs of its own accord. The daemon decides when it runs; while running, it may call back in.

The distinction against a client is initiative, not direction. A harness calling the protocol is not a client, for the same reason a process making a syscall is not the kernel: it does not own the schedule. This is the load-bearing sentence, and it sorts the existing harness/ directory into one of each:

  • search — request in, response out, no lifetime of its own. A harness. It loses its process, its port, its axum server, its mcp.rs, and its service unit.
  • cron — owns a timer and wakes itself. Not a harness under this definition, and not a thing we keep. What replaces it is a harness that asks to be woken, which is the same behaviour with the initiative inverted. See Migration.

The artifact

One ELF, self-describing. A sidecar manifest would mean two artifacts that can disagree, so the ELF answers for itself through required exports:

ExportPurpose
_start()Anchors the other exports so the linker keeps them; never called
berm_tool_<name>() -> (ptr, len)One per tool; the result of one invocation

The manifest is not an export. It is an ELF section, .berm.abi, carrying the same JSON — ABI version, tools, capabilities requested — read straight out of the file. Learning what a harness claims to be must not mean running it, and a describe() export meant exactly that: compile the image, map an address space, enter untrusted code, all to read a string it could have carried as data. A section is also what makes the manifest extractable by any tool with an ELF reader, so the same bytes can be published to a registry without a runtime.

A tool is resolved by its symbol, not by an index. rvtime looks exports up by name (Instance::get_typed_func), so a dispatcher inside the guest would only add an ordinal coupling between the host’s tool list and the guest’s declaration order, in exchange for nothing. Resolving by name also lets the host check Instance::exports() against what the manifest advertises and reject a harness whose description does not match its symbol table. A TypedFunc belongs to the module rather than to a store, so every tool resolves once at load and the handles serve every invocation after it. The prefix exists so a tool named init cannot collide with a reserved export.

Guest functions return at most two registers (translator::RESULT_REGS), which is exactly (ptr, len), and take up to eight arguments (rv::REGISTER_ARGS). A repr(C) pair of u64s returns in a0 and a1 under LP64, which is how a guest hands back a buffer. Arguments travel in by the guest pulling them — arg_len(), then arg_read(ptr, len) — rather than the host writing into the guest’s allocator, so the guest’s heap stays the guest’s business and there is no shared understanding of its layout to get wrong. Any capability returning variable-length data uses the same two-step. One pattern, reused.

_start earns its row the hard way. Nothing inside a harness image references its exports, so --gc-sections discards all of them and the host rejects the result as having no executable .text. An entry point that touches each export address keeps them, which is what rvtime’s own fixtures do. It is pure ceremony from the author’s point of view and therefore belongs to the SDK, not to them.

The manifest carries abi_version. A host that does not recognise it refuses the harness rather than dispatching into a capability the author did not mean.

The export table is the registration

rvtime exposes Instance::exports(). A harness that exports on_wake can be scheduled; one that does not, cannot — and the same rule decides whether it is handed a heap. There is no registration call and no participation manifest — the ELF’s symbol table states what it takes part in, which keeps the “one artifact” property true all the way through lifecycle rather than only for tools.

Do not mirror Hook

Hook (crates/runtime/src/hook.rs) is our internal seam. scoped_schema, on_build_agent, and scoped_tools have the shapes they have because of how the composite hook and per-agent scoping work today. Publishing the trait as a guest ABI freezes it: every later change to Hook breaks every ELF in the field.

The harness ABI exposes the smallest subset that makes real harnesses possible, and grows an export only when a harness needs one. Internal hooks stay internal Rust. Concretely, the exports are call (a tool was invoked), on_wake (a scheduled instant arrived), and on_notify (a subscribed event fired) — three entry points, none of them a mirror of a trait method.

The hard case is events, and it wants subscribing through the protocol, the way a client does, rather than anything invented for harnesses. Granularity is already right: AgentEvent::TextDelta(String) is per-token and on_event runs inline in the streaming loop (crates/runtime/src/engine/execution.rs:82,139) from sync code on an async path, so a guest invocation per token would be unviable — but no exclusion rule is needed to prevent it, because the per-token stream was never on the event bus. The bus carries semantically meaningful topics, which is precisely what a harness can afford.

The bus cannot deliver to a harness today, and that is what blocks on_notify. SubscribeEventMsg is {source, target_agent, once} — a subscription routes an event to an agent — and the client-facing subscribe_events is a stream, which a harness has no lifetime to hold. So there is no protocol:events group in this RFC: naming one would grant a mechanism that does not exist. subscribe_event, unsubscribe_event, and publish_event stay ungrantable until the bus grows a harness as a subscription target, at which point the group is named alongside it. on_notify stays in the ABI because the export table is the registration and a harness that does not export it simply never receives one.

FrequencySurfaceTreatment
Rare (register, unregister, build)lifecycleA short blocking call is acceptable
Once per user messagepreprocessBlocking with a timeout; gated on measurement
Per subscribed eventon_notifyEnqueued, fire-and-forget; the subscription is the filter

Memory is per-invocation, storage is persistent

Each invocation gets a fresh Store: instantiate, run, drop. No state survives in guest memory. Persistence is a capability, namespaced per harness, reached through host calls.

The alternative — one long-lived Store per harness — is the intuitive reading of “joins the runtime’s lifetime” and it is wrong. Store<T> is Send and deliberately not Sync, because entering a guest takes &mut Store. A long-lived harness is therefore a Mutex<Store>, and every call into it serialises: tool dispatch, events, and preprocessing, across every agent and conversation. One harness with a slow event handler becomes a global queue.

Per-invocation stores buy three things beyond that:

  • Reentrancy stops being a memory problem. A harness calls an agent, the agent emits events, the events come back to the same harness — with a long-lived store that is a familiar class of bug; with fresh stores it is another invocation. Storage-level read-modify-write races remain, which is ordinary concurrency rather than memory corruption.
  • Upgrade is a file swap. State was never in the ELF’s address space.
  • A trap costs one invocation. There is no corrupted long-lived heap to reason about afterwards.

The cost is measured, not assumed. A spike embedding rvtime — berm/engine, with a guest built by the SDK — puts a complete invocation at a p50 of ~17µs: Store::new, instantiate, both argument host calls, the guest’s work, reading the result out of guest memory, and teardown. Compiling the ELF is ~15ms cold and ~3ms against the on-disk code cache, and is paid per image rather than per call.

Three things fall out of the measurements, and the third was a surprise.

Against an LLM round trip none of this registers, so per-invocation stores are affordable on the tool path, on preprocess, and on event triggers alike — there is no case for pooling stores or keeping them alive, and the isolation the design wants is free.

The p50 is flat from 16 MiB to 1 GiB of configured guest memory, confirming the address-space reservation is genuinely lazy: a harness that asks for room does not pay for it on every call.

Entering the guest is the expensive part, and a host call is nearly free. A guest entry costs ~13µs; a bare host call costs ~30ns, measured by making a hundred of them inside one invocation. That is a ratio of roughly four hundred, and it decides the shape of the ABI: the host never enters a guest to tell it something.

Handing over the heap is the case that proved it. The obvious design — the host enters the guest with init(start, size) after instantiating — doubled the cost of every invocation, and cost the same whether the region was 64 KiB or 62 MiB, because the work was never the initialization. Declaring a heap so the host could skip that entry was an improvement and still the wrong answer. The right one is that the guest asks: its allocator pulls the bounds through two host calls the first time something allocates, from inside the entry it is already in. A harness that never allocates never asks, so there is nothing to declare, no conditional export, and no branch in the host.

The same reasoning is why arguments are pulled rather than pushed, and it is the rule to apply to every capability added later.

Measured on one arm64 machine with a 72 KB guest, a 256-byte payload, and static buffers rather than an allocator — enough to size the boundary, not a claim about what a real harness’s own work will cost.

Capabilities

Host functions are keyed by number, and a number with nothing registered traps as Trap::UnknownHostCall(n). So the grant is not a check somebody has to write and remember to enforce — the Linker a harness is instantiated with is its capability set. Enforcement is the absence of code.

Not everything numbered is a grant. args.len, args.read, fail, heap.start, heap.size, and log carry the invocation itself — a harness without them cannot receive its arguments, report failure, allocate, or be debugged — so they are unconditionally in the Linker and never appear in a declaration. Logging in particular is deliberately not grantable: it writes host-side only, and an author whose harness a user installed without it has no way to find out why it traps.

Above that line, two families, and they warrant different postures:

  • Host-provided — one number each; a bad grant leaks data outward. These have no protocol equivalent: per-harness persistence in particular is not a ClientMessage, and inventing one so that everything goes through a single door would be symmetry for its own sake.
  • The protocol — one number, carrying the whole of ClientMessage. A bad grant spends the user’s tokens, reads their conversations, and deletes their agents.

Two rules that do not bend:

  1. Names are permanent; numbers are derived. ecall carries a number in a7, but we do not assign it — it is a hash of the capability’s name, computed at compile time on both sides. So the contract a third party ships against is berm.http.fetch, not 16: adding a capability cannot collide with someone else’s allocation, there is no registry of integers to maintain, and the thing we version and deprecate is a name. Solana reaches the same place by hashing syscall symbols; we get it without changing rvtime, whose linker is happy with any u64 key.
  2. Requested is not granted. The manifest states what a harness wants — documentation, enough for a client to prompt. The declaration states what it gets. The daemon never infers one from the other.

Every capability we write needs its own timeout. rvtime’s interrupt check covers every case of non-termination in guest code — a guest can only run forever by looping, and unbounded recursion exhausts the native stack and traps — so the remaining way to wedge a worker forever is a host call of ours that never returns.

The set

Each name is here because a harness on the migration path needs it, and no name is here for a harness we have not written:

NameNeeded byShape
clockcron as a harnessUTC instant plus the host’s local offset — a wall-clock intent is not an instant
httpsearch; every engine is fetch-then-parseRequest out, response in, over a declared host allowlist
fsread and editFile operations bounded by a declared root
execbashA command, an environment, a working directory, and a timeout
protocoleverything that touches the runtimeOne number over ClientMessage, allowlisted on decode

exec subsumes fs. Anything fs does, a shell does with cat, tee, and sed, so these are not two rungs of a privilege ladder and a harness holding exec gains nothing from being denied fs. The split earns its keep for harnesses that are not the OS one: reading files without acquiring a shell is the common third-party shape, and fs is what it holds. Within the OS harness the two coexist for implementation reasons rather than containment ones — read built on exec("cat") would lose its size cap and its line windowing and gain shell-quoting bugs.

A root is not a working directory. The declaration names a root, which is the boundary: a path resolving outside it is refused host-side, and nothing in a call can widen it. The working directory is a call parameter defaulting to the root — where inside the boundary this invocation operates. Keeping them separate is what makes the OS harness stateless: the model says where it is working on each call, so there is no cwd to carry between invocations and no conversation state to reconstruct.

exec has no command filtering, and should not grow any. A deny list guards against a model choosing something destructive; it does nothing against a harness author, who is arbitrary code and simply calls the capability with what they want. Putting one behind exec would move a behaviour guardrail into the capability layer and buy the appearance of containment rather than containment. What actually bounds exec is the grant: a harness that does not hold it cannot reach a shell, and one that does can do anything the user can. Deciding who holds it is an install-time judgement, not a runtime check.

http is the exception, because there the allowlist is not theatre: a harness declares the hosts it reaches, the check is a comparison the guest cannot influence, and the declaration is readable out of the ELF without running it. The grant is those hosts, not the network — the same relationship root has to fs and exec, and the reason both carry an argument in the declaration rather than only a name.

Excluded deliberately, and why:

ExcludedWhy
storagePersistence is a capability (see Memory is per-invocation) — but the first harness has nothing to persist. Dropping read-before-edit leaves the OS harness stateless, and a stateless harness needs no store. It arrives with the first harness that outlives its own invocation, which is the re-arming one.
randomA guest has no entropy without us, so this is a real gap — but nothing on the path to the first harness needs it. It arrives with the first re-arming harness that wants jittered backoff, and because numbers are hashed from names, adding it then costs nothing it would have saved by being here now.
envReading the daemon’s environment is reading the user’s secrets. Whatever a harness needs comes through its declaration.
netRaw sockets are a bypass of every host http was allowlisted against. http is the network surface.
sleepA sleeping guest pins a worker. Waiting is protocol:schedule and on_wake.
threadConcurrency belongs to the queue, which is where the timeout, the rate limit, and the single-in-flight rule already live.
mcpA harness reaching MCP servers is the daemon’s own privilege laundered through a grant that looks smaller than it is.
memorycrates/memory is one global BM25 store, which is the opposite of per-harness namespacing. Its tools are thin wrappers over state a harness cannot own, so they stay an internal hook until there is a reason otherwise.
htmlDeferred rather than refused — it is search’s problem and is decided with search, not before it.

The protocol is the syscall surface

Everything a harness does to the runtime — send to an agent, subscribe to an event, schedule a wake, read history — is already a ClientMessage. There is no reason to invent a second vocabulary for the same operations, and a second vocabulary would drift from the first.

One capability carries the entire protocol, with a serialized ClientMessage as its payload. The alternative — a capability per RPC, crabtalk.protocol.send and so on — is workable now that names rather than numbers are the contract, and it is still the wrong shape: ClientMessage is a oneof, so the message type is already a discriminant inside the payload. Spending a second discriminant on the ABI duplicates what protobuf carries, and buys a per-RPC SDK release before any harness can reach a newly added message. One door also means one place to enforce the allowlist, log protocol access, and rate-limit it.

The grant is therefore two-level:

  • The number gates the family. Ungranted, it is absent from the Linker and traps — a harness with no protocol grant cannot reach the runtime at all, and that enforcement is still the absence of code.
  • An allowlist gates which message types pass, checked once on decode.

The guest speaks the daemon’s own message types, which the fallback in this RFC’s first draft treated as the likely outcome and the preferred one as unexamined. It is settled, and it is one crate rather than two emissions: crates/proto owns crabtalk.proto and both worlds compile it, with std deciding what comes along. On is the daemon’s: serde derives, and the conversions that carry an anyhow::Error or an LLM type. Off is a guest’s — no_std over an allocator, with btree_map throughout because map fields otherwise reach for std::collections::HashMap, and a crate alias because prost writes ::prost:: paths and a member cannot turn off default features it inherits. One schema, one crate, and no way for the two to drift.

Three rules govern what goes in that allowlist:

The protocol is the vocabulary, not the grant. ClientMessage is what the user’s own trusted UI may do, and it includes DeleteAgent, UpdateAgent, and Reload. A third-party harness holding those can delete the agents it was installed to help. Default-deny, granted in groups named by intent rather than one flag per message type.

Authority rides on the invocation, not only the declaration. A harness invoked during agent X’s tool call and calling send is acting as someone. The invocation carries (agent, conversation, sender), and protocol calls are scoped to that context unless a broader grant exists. The declaration grants classes of operation; the invocation supplies the instance. It is the difference between handing a process a file descriptor and granting it permission to open any file.

Filesystem and command capabilities are scoped, not sandboxed. A harness granted exec can do anything the user can, and no amount of address-space confinement changes that. fs is bounded by a path subtree enforced host-side; exec is bounded by the grant and nothing finer, for the reason given above. Installing OS tools as a harness makes them modular and placeable; it does not make bash safe, and this RFC should not be read as claiming otherwise.

The groups

ClientMessage sorts by blast radius rather than by the section of the proto a message happens to sit in:

GroupMessages
protocol:readping, get_stats, list_agents, get_agent, list_skills, list_models, list_subscriptions
protocol:historylist_conversations, list_active_conversations, get_conversation_history
protocol:sendsend, stream, steer_session
protocol:scheduleThe wake, its cancellation, and its listing — none of which exist yet

steer_session is in protocol:send because injecting content into a live run has the same blast radius as starting one, which the proto’s own grouping obscures by filing it under Steering.

Everything else is in no group a third party can hold, in three kinds:

  • Destructive or configuring — create_agent, update_agent, delete_agent, rename_agent, delete_conversation, upsert_mcp, delete_mcp, reconnect_mcp, set_active_model, kill, compact, reload, get_config, and extension, which is opaque bytes for downstream products.
  • Answering for someone else — reply_to_ask and reply_to_tool. Both respond to a request routed to a human or a client, and a harness answering one is impersonation rather than capability.
  • Credential reads — list_mcps, whose payload is substantially the credential: McpInfo.auth is documented as the full Authorization header value, and env is the server’s environment.

Two messages are excluded by shape rather than by policy. subscribe_events and subscribe_mcp_events are streams, and a harness has no lifetime to hold one; there is nothing to decide about them.

A protocol read is a credential read until one field is dropped. AgentInfo.config carries the full AgentConfig as JSON, and AgentConfig.mcps holds McpServerConfig by value — env and auth included. So list_agents hands a harness every MCP bearer token the user has, and protocol:read is unshippable as written. The fix is small and the existing consumer proves it is sufficient: apps/tui/src/repl/delegate.rs reads only name and description off AgentInfo and never touches .config, so the protocol door blanks that one field for harnesses. This is the harness boundary paying for a bill RFC 0193 deferred — secrets stored as literal values — and the redaction should be understood as holding the line until that is paid, not as settling it.

Placement

Harnesses run in the runtime. The client never executes one. The daemon’s machine is the workspace, and a client — TUI, web, phone — is a view onto it. Where the files are is not a spectrum to be resolved per deployment: if the work is on machine M, the runtime for that work is on machine M, which is what one-runtime-per-user (RFC 0193) already implies.

This dissolves the “client tool” category, and the reason to state it as a rule rather than a table is that the table gets the failure direction wrong. Treating client-hosted execution as one valid row among several is how bash ends up somewhere a web client cannot follow: a browser has no filesystem to offer, so a design that puts OS tools in the client hasn’t chosen a deployment, it has decided which clients are permanently second-class. Today’s rule — crates/hooks/src/os/mod.rs:3, “the daemon never executes these” — is exactly that decision, and it inverts here.

A pleasant consequence of ruling out client-side hosting: the JIT question disappears. Hosting harnesses needs one and iOS forbids one, which would have been a hard ceiling on rich mobile clients. No client hosts, so no client needs a JIT, and a phone is a view for the same reason every other client is.

A client tool is one whose result requires a human to be present. ask_user passes that test and stays a forwarded tool; read fails it and becomes a harness capability. The code has already half-found this line, special-casing ask_user out of the generic forward path at crates/sdk/src/stream.rs:82.

The reason this matters is not tidiness. crates/crabtalk/src/bridge.rs states the current contract: “A client’s tools are exactly what it declares in StreamMsg.tools. There is no default set: the daemon cannot execute a client tool, so advertising one the client never claimed only buys a forward nobody answers — a hang until the timeout, not a fallback.” A run with no client attached therefore has no tools at all. That is tolerable while every run has a human watching it, and this RFC ends that: a scheduled wake, an event-triggered invocation, and a harness calling send all produce agent runs with no client. Tools-in-client does not degrade when scheduling lands — it stops working.

Forwarding also gets the granularity wrong. A harness invocation crosses the boundary once and makes its fifty filesystem calls locally; forwarding primitives crosses fifty times. The agent still pays the one round trip it can never avoid, because the model is on the far side waiting for a result. What changes is everything else.

Two consequences worth stating. A thin client — the proto carries a swift_prefix option, so they are already in the picture — gains the same tools as the TUI, because tools no longer live in the client; today it cannot have bash by construction. And approval inverts: with execution in the runtime, prompts cross the wire instead of results, which is the rare path rather than the frequent one.

What a client keeps is the work that genuinely needs it: rendering the stream, sending input, answering ask_user, and prompting for approvals.

Bounding invocation chains

Making the protocol callable closes a loop that does not exist today: a harness calls send, an agent runs, agent:{name}:done publishes, the subscription wakes the harness, which calls send. The queue will service that forever.

Every invocation therefore carries a chain depth, incremented when work is enqueued as a consequence of a harness call and refused past a limit. A budget over the chain — invocations, or the tokens its agent calls spend — is the same idea with a more useful unit, and is the one place where the contract analogy is load-bearing rather than illustrative: this is gas.

Bootstrap has a floor. The queue, the due-set’s storage, and protocol dispatch itself cannot be harnesses.

Harnesses are the edge that isn’t the client

RFC 0189 drew the line as mechanism in the daemon, policy at the edge, and “the edge” has since been read as “the client process” — because at the time it was the only other place available. What 0189 actually objected to was the daemon deciding on the user’s behalf with hardcoded heuristics it could not be argued out of.

A harness is a third location. Not daemon-core, not client: installable, agent-declared, forkable, replaceable. Policy in a harness satisfies 0189 completely — the daemon still decides nothing — without requiring every client to grow its own implementation of that policy.

The principle gains a clause rather than losing one: mechanism in the daemon, policy at the edge, and a harness is edge code that happens to run in the runtime.

This is what keeps “the client is a thing that calls the daemon” from being in tension with “the daemon does not decide for you.” A client’s job reduces to rendering the stream, sending user input, answering ask_user, and prompting for approvals. What it must not become is the only place a capability exists, because then every client reimplements it and each client’s UI constraints leak into what agents can do.

Delegation as a harness

RFC 0203, landed the same day as this one, moved delegate from a daemon hook to a client tool. Its reasoning holds under its premise, and the premise is the one this RFC changes:

“Sub-agents could think, reach memory, skills, and MCP, and nothing else. Daemon-side orchestration was orchestrating agents with no hands.”

Delegation moved to the client because that is where the hands were. Once tools are harnesses in the runtime, sub-agents have hands wherever they run, and the bridge work 0203 priced out — multiplexing sub-conversation forwards, namespacing call_ids across conversations, propagating cancellation, keeping listener teardown from killing in-flight calls — is not paid by anyone, because nothing is forwarded.

Two compromises in 0203’s design exist only because the orchestrator is a user interface. Sub-agents are not offered ask_user, since “the REPL’s ask modal is a single slot that two concurrent sub-agents would corrupt” — a rendering constraint bounding agent capability. And they are not offered delegate, since withholding it “caps recursion at one level with no depth counter to maintain” — a counter this design already has, as the chain depth above.

The decisive argument is reach. Client-side orchestration means every client implements orchestration: the TUI has delegation, telegram does not, a thin client never will, and a scheduled run has no client to have it. As a harness it is installed once and inherited by all of them, including the runs with nobody attached.

What survives from 0203 is its mechanism, entirely — sub-conversations keyed by a distinct sender, each an ordinary persisted conversation, no protocol change:

stream(agent="reviewer", sender="delegate:{call_id}:0", tools=[…])

A delegation harness makes exactly those calls over the protocol from inside the runtime. 0203 proved the primitive; only the caller moves. This is a relocation, and specifically not a return to DelegateHook, which had no tools, no depth counter, and no forkability.

The sequence is forced. OS capabilities become a harness first, delegation second. Reversed, this rebuilds 0203’s original complaint exactly: an orchestrator in the runtime handing out sub-agents with no hands.

Declaration

Agents own their harnesses by value, following RFC 0193’s argument for MCP and more strongly: a hash-pinned ELF is more portable than a command + args + env triple that assumes the destination machine already has the binary.

[[harnesses]]
name = "search"
source = "github:crabtalk/search@v0.1.0"
sha256 = "9f2a…"
capabilities = ["http", "clock"]
hosts = ["bing.com", "search.brave.com", "html.duckduckgo.com", "mojeek.com", "*.wikipedia.org"]

[[harnesses]]
name = "os"
source = "builtin"
capabilities = ["fs", "exec"]
root = "/Users/clearloop/code/crabtalk"

[[harnesses]]
name = "reminders"
source = "github:someone/reminders@v2.1.0"
sha256 = "c40e…"
capabilities = ["clock", "protocol:schedule", "protocol:send"]

AgentConfig.harnesses: Vec<HarnessConfig> sits beside mcps. Tools land in the agent’s tool list under their own names and schemas, read from the manifest at register time — the per-agent declaration is already the gate, so there is no meta-tool indirection to pay for.

Two capabilities take an argument rather than only a name, and both follow the same rule: the grant is the argument. hosts bounds http and root bounds fs and exec, and either one absent grants nothing — http with no hosts reaches no host, fs with no root reaches no path. A grant that decays to a no-op when under-specified is the right failure direction for the capabilities whose whole point is a boundary.

One agent is one workspace. With root on the declaration, working on a second project means a second agent rather than retargeting the first. That follows from agents owning their harnesses by value, and it is a product consequence rather than an implementation detail: it is visible in how someone works across repositories.

The daemon does not download code. crabup fetches and verifies; the daemon loads what is present and errors if it is not. A daemon that fetches third-party code because an agent config named a URL is a daemon making a policy decision with a network connection.

Execution: one queue

Every path that can start a guest goes through one queue, with three trigger kinds:

TriggerEntry pointEnqueued asLatency
Tool callcallHigh priority, with a reply channel — a model is waitingCritical
Due instanton_wakeThe scheduled caseTolerant
Subscribed eventon_notifyFire-and-forget; the subscription is the filterTolerant

Behind all three, one executor: the blocking pool, the per-invocation timeout, the Interrupt handle held by a watchdog, the fresh Store. Guest execution blocks a thread, so it runs under spawn_blocking; capabilities needing async work block_on from inside that thread.

The queue is the security boundary as much as the scheduling one. Concurrency, timeout, per-harness rate, and single-in-flight all have exactly one place to live, and nothing starts a guest without passing through it.

Scheduling

The due-set understands one thing: wake harness H at instant T with payload P. A one-shot is the base case; recurrence is a harness that re-arms itself when it wakes.

Scheduling is a protocol message, not a bespoke capability, which is what makes the due-set reachable by everyone who needs it: a harness re-arming itself, a client asking for “foo tomorrow at 08:00,” or a model doing so on the user’s behalf all send the same RPC and land in the same heap. The proto once carried CreateCron / DeleteCron / ListCrons at tags 27-29, removed when cron went standalone; the replacement is not those messages returning but a smaller one — an instant, a target, a payload — with recurrence living in the harness.

This is not a simplification for its own sake — it is strictly more expressive than teaching the host a schedule language. “Every five minutes with backoff after failure,” “the third Tuesday unless it’s a holiday,” “hourly but not overnight” are all re-arm logic, and none of them need a host change. The cron crate moves into the guest SDK, where an author uses it or does not. The host never learns what a cron expression is and never acquires an opinion about DST.

The data structure is a min-heap of one-shots keyed by instant — (when, harness, id, payload) — owned by the host, persisted, and reloaded at startup without running a single guest. The host never asks a guest whether it is due; that would be an instantiation per harness per tick to answer a question the host already knows.

The payload matters because not everything that schedules is a harness scheduling itself. A person, or a model on their behalf, asks for “foo tomorrow at 08:00” — so the due-set is reachable from outside a guest call, and the wake carries enough for the harness to know which of many pending things is due.

Consequences that follow:

  • Wall-clock intent is not an instant. “08:00 tomorrow” needs a timezone, so clock exposes the host’s local offset, not only UTC. Because a recurring harness re-resolves local to UTC at every re-arm, DST is handled by construction — where a host-side cron parser would have to be right about it forever, in code no harness author can fix.
  • Missed occurrences are policy. The daemon was off for three hours and thirty schedules came due. Firing all thirty is a thundering herd; dropping them silently is data loss. The wake carries both scheduled_at and now, and the harness decides — a reminder still means something two hours late, a standup post may not.
  • Overrun does not overlap. One in-flight invocation per harness per schedule; the next occurrence skips or defers. Without it the first badly written harness pins the pool.
  • Backpressure is visible. When a drain’s due-set exceeds the worker budget, the remainder spills to the next drain and says so. A queue that grows silently is how this becomes unexplainable at 3am.
  • Re-arm before the body. A harness that traps before re-arming silently stops being scheduled. The ABI cannot enforce ordering; the SDK’s recurring wrapper re-arms first and runs the body second, so a panic costs one occurrence rather than the schedule.

The SDK is the contract

The SDK here is berm/sdk — a guest library, no_std, compiled for riscv64imac-unknown-none-elf. It is not crates/client (published as crabtalk-client, and named crabtalk-sdk before this RFC), which is a std and tokio library for talking to the daemon over a socket. The two can never merge: one lives in a world with sockets and an async runtime, the other in a world with neither.

They are nonetheless the same kind of thing seen from two sides. Both are protocol clients — crates/client sends a ClientMessage over a socket, a harness sends one through a single ecall — so their surfaces should rhyme wherever they do the same work, even though they cannot share a line of transport code. Whether they can share the generated protocol types is an open question below.

Third parties should never see a call number, a (ptr, len), or a register convention. They see a library: declare tools, implement the lifecycle points you care about, call typed capability wrappers. That library decides whether anyone builds a harness at all, and it is where conventions like re-arm-before-body live.

It also buys room to move: the ABI can be revised as long as the SDK absorbs it — except for ELFs already shipped, which are frozen against the capability names they were compiled with, which is what abi_version is for.

The SDK also builds for the host, and that is not a curiosity — it is what lets an author cargo test their handlers. Off the guest’s target the exports are ordinary functions and the buffers ordinary memory, so test::call invokes a tool exactly as the host does: the same argument transfer, the same buffer limits, the same failure channel. Capabilities are served by a stand-in host a test can set; one with no stand-in panics naming itself rather than returning a plausible zero. Solana’s programs work this way for the same reason, and it is the difference between finding a mishandled empty string in a second and finding it through a cross-compile and a daemon.

The SDK’s first obligation is the one the spike tripped over: generate _start, and make it reference every export. Omit it and the linker discards the whole image, which surfaces as the host refusing a guest that appears, from the source, to export exactly what it should. Finding that took one failed run with rvtime’s fixtures open alongside; an author without them would lose an afternoon to it. The same class of obligation covers linking with --emit-relocs and building for riscv64imac-unknown-none-elf — all of it belongs in a template and a build profile that an author never edits.

Beneath that, the SDK builds on rvtime-guest for the ecall wrappers rather than reimplementing them; the spike confirmed the swap is free, producing a byte-identical image. What our SDK owns is everything harness-shaped above that line: the entry anchor, the describe/call scaffolding, and typed capability wrappers.

What a guest may contain is decided by rvtime’s control-flow integrity, and building the first real harness moved that line twice. An indirect jump is legal only if the target is both named by a relocation and known as a function entry, which is a real guarantee and worth keeping — but it was reading half the evidence. Two gaps, found by porting the OS tools and fixed in crabtalk/rvtime:

  • Address-taken functions were only recognised in data. indirect_targets accepted R_RISCV_64 — a pointer stored in a vtable or a jump table — and ignored R_RISCV_PCREL_HI20, the auipc/addi pair that materialises a function address into a register. That is how core::fmt builds the formatter pointers it calls through, so any guest that formats a non-trivial argument list trapped, along with serde_json::Value and every Box<dyn Trait>. Seventy such targets in the OS guest were invisible.
  • Jump tables land inside a function, and the dispatch table cannot express that. Eighty-four relocation-named addresses in the OS guest point into the middle of a function — LLVM’s lowering for a dense match. The dispatch table maps an entry address to a compiled function, and a CLIF function has one entry, so a mid-function target has no slot it could ever fill. These are now lowered as local branches: the translator compares the computed address against the relocation-named addresses inside the current function and jumps to the matching block, falling through to the ordinary dispatch check when none match. The candidates are the same evidence the dispatch path already trusts, so nothing is widened.

Both are reachable from a model’s own output rather than exotic: an unknown field in a tool call takes serde into IgnoredAny, and a wrong argument type takes it into peek_invalid_type, and both are jump tables. Untreated they surfaced as a wild jump and a panic rather than an argument error.

The lesson for the ABI is that guest-side avoidance was never a viable answer. Telling authors not to use dyn or core::fmt is not a contract anyone would build against, and the workaround this design did keep — deserializing tool arguments into structs rather than reading a serde_json::Value — survives only because it is better code anyway.

One gap to design before the first external author hits it: when a harness traps, its author currently gets guest memory fault at 0x… and nothing else. We hold the ELF with relocations and function names in module.program().functions, so mapping a trap address back to a function name is available to us. A log capability plus symbolised traps is plausibly the difference between people building harnesses and people giving up. BadIndirectTarget now at least carries the address it refused — diagnosing the two gaps above took a probe matrix and a hand-written relocation dump because it did not — but an address is not yet a name.

Layout

berm is the sandbox; crabtalk is one thing that embeds it. The engine loads an ELF, grants it capabilities, and invokes it. It does not know what an agent is, what a ClientMessage is, or that a daemon exists — and it must not, because the same sandbox runs in our cloud with no crabtalk around it.

berm/engine       the sandbox — loader, ABI, grants, fs and exec
berm/sdk          guest library third parties build against
berm/codegen      the `#[harness]` macro
crates/berm       crabtalk's side — the Hook impl and the protocol capability
crates/proto      the message types both sides speak, std and no_std
harness/*         the harnesses themselves

harness/ holds every harness regardless of what it asks for — os needs only fs and exec and so runs under any embedder, peers needs crabtalk.protocol.call and cannot run anywhere else — because where a harness lives is about what it is, not who can host it. berm/ and harness/ leave this repository together when berm does; what a harness needs from its host is settled by its capabilities, which the declaration already states.

That boundary is enforced rather than intended: berm depends on anyhow, object, rvtime, serde, serde_json, and tracing, and on no crate of ours. It cannot grow one without crabtalk-berm moving back into it, which is a change nobody makes by accident.

What made the split possible was deleting a special case rather than relocating it. The engine used to register the protocol capability itself, which meant it knew crabtalk’s message type. It now takes a Capability — a name and a closure over bytes — from whoever embeds it, hashed to its call number exactly as fs and exec are, so an embedder’s capability is not a second class of thing. fs and exec stay in the engine because they are about the machine, which every host has; anything about the host is supplied this way. Embedding berm never means patching it.

The namespace follows the same line, and it is the part that is permanent:

berm.log  berm.args.*  berm.fail  berm.result.read  berm.heap.*
berm.fs.read  berm.fs.write  berm.exec.run          ← any embedder implements these
crabtalk.protocol.call                              ← only crabtalk has one

There is no crates/rvtime. rvtime is published; a crate whose content is a re-export of a published dependency is a file that exists only to drift. What we need is the embedding, and that is berm/engine.

Distribution

crabup’s verbs already fit — crabup add attaches a harness, crabup remove detaches it. What changes is what gets fetched:

  • One .elf per release, no platform matrix. Apps keep theirs.
  • Entry.label becomes None for harnesses — the field that already means non-serviceable. No launchd or systemd unit, because there is no process.
  • The declared sha256 is verified on fetch and on load.

What this commits us to

Once the protocol is a syscall surface and harnesses are scheduled, confined, capability-granted units of code, the daemon is a kernel. That is a coherent thing to be and it is where this design points, but it is worth naming as a decision rather than discovering it later.

The bill: every protocol change now carries ABI weight; the queue acquires the obligations of a scheduler, including fairness and accounting; and third-party authors will expect what OS users expect — stable interfaces, resource limits they can see, and a debugging story better than a fault address. The reserved-tag discipline already visible in the proto is evidence this is a bill we can pay. It should still be paid deliberately.

Migration

harness/cron is deleted. Its scheduler loop (harness/cron/src/runner.rs:139-149) already sleeps until the next occurrence inside a KeepAlive launchd process — there is no StartCalendarInterval, so the daemon has to be alive for 08:00 to fire today exactly as it would inside the runtime. Moving the mechanism inward costs one process, one service unit, and one timer task per schedule, and returns a single waiter over a sorted set. RFC 0080 is superseded; the entry leaves the crabup registry. Downstream apps that need scheduling before the harness path lands can do it with the SDK in a few dozen lines, which is the argument for not carrying a service to do it for them.

harness/search becomes a guest. It loses mcp.rs, its mcp feature, and the rmcp/axum/schemars dependencies with it. Its engines are reqwest and scraper, which are std and cannot cross into a no_std guest as they are — so search is not the first harness we ship. See Unresolved questions.

crates/hooks/src/os becomes a harness, and it is the first one. The daemon stops compiling in an opinion about what a filesystem tool is and installs one instead. It goes first rather than fourth because it is the only harness on this list that needs neither the queue nor a protocol grant — it is pure tool calls, no on_wake, no on_notify, no chain depth — and because the seam is already cut: OsHook::execute(name, args) -> Result<String, String> is the harness call signature already, down to the inner Result telling a failed tool from one that returned the word “error”.

Both pieces of state the hook holds are removed rather than moved.

The cwd was fixed at construction from the client’s own current_dir. It becomes a call parameter over a declared root — see A root is not a working directory. It does not come back over the wire: SendMsg tag 5 was removed with the note “the daemon does not read the user’s filesystem,” and that field should stay dead, because it carried the client’s directory to a daemon that executed nothing — a different thing from the daemon owning a workspace.

Read-before-edit is dropped, not relocated. A Mutex<HashSet<PathBuf>> (crates/hooks/src/os/mod.rs:34) records paths this instance has read, and edit refuses a path missing from it. It is redundant against the tool’s own shape: edit is a find-and-replace requiring old_string to appear exactly once (crates/hooks/src/os/edit.rs:60-68), so an edit cannot be a blind overwrite — naming a unique string means already knowing the file’s current content. Nor does the set catch staleness, since it stores no mtime and no hash; it catches only “you never looked,” which uniqueness already catches wherever it matters. What remains is a guardrail against the model making a mistake, sitting a layer below where behaviour belongs — the same misplacement as a command deny list, and it goes for the same reason.

ask_user stays a forwarded client tool, and so does delegate until it becomes a harness of its own. Everything else the TUI used to declare is gone from StreamMsg.tools, and sub_agent_tools is now empty — not as a restriction but as the reverse: a sub-agent’s hands come from its own agent config, so the orchestrating client no longer has to offer anything for a delegated run to be able to do work. That is the condition RFC 0203 said it was waiting for.

Images are built by make harness and installed under HARNESSES_DIR, which is derived from the configuration directory rather than written out, so pointing crabtalk elsewhere points harness lookup there too. The daemon reads that directory and never writes to it.

delegate becomes a harness, after the OS one. RFC 0203’s client-side implementation stands until then; see Delegation as a harness for why the order is not optional. Its sender-keyed sub-conversation mechanism is unchanged by the move.

Nothing changes for MCP. crates/mcp stays as it is. A remote HTTP MCP server is someone else’s process on someone else’s machine and there is nothing to confine. What harnesses displace over time is the stdio case: spawning a local binary with the daemon’s privileges.

Unresolved questions

  • How a harness becomes an event subscription target. SubscribeEventMsg routes to an agent, so on_notify has no delivery path and protocol:events has nothing to grant. Whether the target becomes a sum type on the existing subscription or something else is undesigned, and it gates the third trigger kind in the queue.
  • Whether http fans out. harness/search’s aggregator runs five engines concurrently (harness/search/src/aggregator.rs), and a guest is synchronous — a literal port makes search five sequential fetches. Issuing many requests and then collecting them is the same pull shape as arg_len/arg_read and does not change the capability’s name, so it is a shape question that can be answered when search is ported.
  • What a real harness’s own work costs. The boundary is measured at ~12µs; the guest that produced that number has static buffers and no parser. An allocator, JSON, and actual logic are the author’s cost rather than the design’s, but a harness on the preprocess path is worth profiling before it is normal to put one there.
  • Schedule granularity. The finest interval the due-set will honour is a product decision, not a number to pick here.
  • A logical epoch counter. Not needed for correctness with a sorted due-set, but “harness X ran at epoch N” is a coordinate that makes replay, tests, and per-epoch rate limits legible. If we want it, it should be a counter incremented per drain rather than a wall-clock heartbeat.
  • Whether one agent is one workspace stays true. root on the declaration is the simplest thing that works and it means a second project is a second agent. Whether that holds under real use — or wants a way to retarget an agent’s root, which is a different design — is worth revisiting once the OS harness is in hand rather than guessed at now.
  • Whether other policy follows delegation. RFC 0189 handed compaction timing to clients on the same reasoning that handed them delegation, and the same argument — every client reimplements it, and a clientless run has none of it — applies unchanged. Left alone deliberately until a delegation harness exists to learn from.
  • Approval. With execution in the runtime, a capability grant that needs a human turns into a prompt crossing the wire. Where the answer is stored, and whether it is remembered per agent or per invocation, is undesigned.
  • harness/search’s engines. Either the host offers HTML querying as a capability (the host keeps scraper) or the guest gains a no_std parser. This is a real rewrite and it should not ride along inside the foundational change.

Alternatives considered

A long-lived Store per harness. The intuitive reading of “joins the runtime’s lifetime.” Rejected: Store is Send, not Sync, so it becomes a mutex that serialises every call into that harness across the whole runtime, and it re-introduces reentrancy as a memory-safety concern. Per-invocation memory with explicit storage gets the same persistence with none of it.

Host-side cron expressions. The host parses a schedule string and re-arms. Rejected: the host acquires a scheduling DSL and a permanent DST obligation, and the result is less expressive than a guest that computes its own next instant. Absolute instants plus guest re-arming is smaller and does more.

A fixed block time. A heartbeat that scans for due work each tick. Rejected: blockchains poll on a period because they need consensus on an ordering, and we do not have that problem. The cost is a granularity floor on every schedule — “08:00” becomes “the first tick at or after 08:00” — plus a wakeup every period whether or not anything is due. A sorted due-set with sleep-until-earliest gets the same batching, fires exactly, and idles at zero.

Mirroring the whole Hook trait. Rejected: it publishes an internal seam as a public ABI and makes every future Hook change a breaking change for every shipped ELF.

One capability per protocol RPC. The literal reading of “the protocol is the capability set,” and genuinely tempting: each RPC gated by the presence of its own closure would make protocol grants enforcement-by-absence like every other capability, with no decode-time allowlist. Rejected on duplication rather than on compatibility — ClientMessage is a oneof, so the message type is already a discriminant in the payload, and putting a second one in the ABI means thirty-six registrations per harness plus an SDK release before a harness can reach any newly added message. The decode-time allowlist is one match statement in one place, and it is also where logging and rate-limiting want to live.

Keeping OS tools and delegation client-side. The status quo, and correct while every agent run has a human attached to it. Rejected because this RFC removes that condition: scheduled wakes, subscribed events, and harness-initiated sends all produce runs with no client, and crates/crabtalk/src/bridge.rs is explicit that a run with no client has no tools — an unanswered forward is “a hang until the timeout, not a fallback.” The alternative also leaves each client to reimplement orchestration, which is why telegram has no delegation today.

A bespoke event-delivery mechanism for harnesses. An on_event export fed by a hand-picked subset of AgentEvent. Rejected once it was clear the event bus already exists and already carries the right granularity — SubscribeEventMsg and topics like agent:{name}:done. Harnesses subscribe the way clients do, and the per-token stream is excluded by never having been on the bus rather than by a filter we maintain.

WebAssembly instead of a RISC-V ELF. The mainstream choice, with a more mature third-party toolchain story and a component model that solves interface description properly. Chosen against because we own rvtime end to end — when a harness needs something the runtime cannot express, that is a PR to crabtalk/rvtime rather than an upstream negotiation — and because its numbered-ecall host interface is already the shape a capability grant wants. The costs are real and stated: no_std plus alloc only, RV64IMAC with soft float, POSIX-only hosts, and --emit-relocs required at link time.

Keeping search as an in-process Rust Hook. Simpler than everything above and correct for our own code. Rejected as the general answer because it does not extend to code we did not write, which is the entire problem.

Out of scope

  • Remote MCP. Unchanged, and not a candidate.
  • Secrets. RFC 0193’s line holds: the daemon stores literal values and whatever sits above it resolves them.
  • The guest SDK’s API surface. It deserves its own RFC once the ABI has carried a real harness.
  • Windows. rvtime’s memory and traps are POSIX; harnesses do not run there.
  • A registry protocol. #150 covers pluggable sources; this RFC assumes crabup fetching a hash-pinned artifact from a release.

0207 - Store

Summary

Persistence is one primitive. A store implements five methods — get, put, delete, scan_keys, scan — and is thereby already an Agents, a Sessions, a Memory, a Skills, a Harnesses and a TextSearch, because each of those traits is bounded on KVStorage, carries its own method bodies, and is blanket-implemented for anything satisfying it. There is no wrapper to construct and nothing to wire. Secondary indexes are keys; ranked full-text search is BM25 over the same keyspace; and the shipped backend is crabdb, an append-only single-file store with a resident key index, replacing SQLite.

Motivation

The Storage trait this replaces had twenty methods and one implementation per backend, so every backend reimplemented sessions, agents, skills and config from scratch. Splitting it into a KV half and a SQL half did not fix that — the SQL half had twenty domain-named methods of its own (index_agent, latest_session, skill_summaries) against four hand-rolled entity tables holding data the KV half already held. It was the domain model restated one layer down, and a backend author still wrote bespoke SQL per entity. Narrowness is not the same property as primitiveness: a trait can be closed and still be the domain in disguise.

The observation that collapses it is that an ordered lookup, a name resolution and a set membership are all secondary indexes, and a secondary index is just more keys. find_latest_session does not need a query planner; it needs keys that sort. Once that is true of everything except ranked full-text, and ranked full-text turns out to be an inverted index — a map from term to documents, which is what a keyspace is — nothing is left that a relational engine was doing.

What remained of SQLite at that point was one table with three columns, no joins, no aggregates and no transactions: a parser and a query planner running on every get to perform a B-tree lookup. A runtime crate has no business linking a database, and the cost of one is not paid for by what this design uses of it.

Design

One primitive

#![allow(unused)]
fn main() {
pub trait KVStorage: Send + Sync + 'static {
    fn get(&self, col: Column, key: &[u8]) -> impl Future<Output = Result<Option<Vec<u8>>>> + Send;
    fn put(&self, col: Column, key: &[u8], value: &[u8]) -> impl Future<Output = Result<()>> + Send;
    fn delete(&self, col: Column, key: &[u8]) -> impl Future<Output = Result<bool>> + Send;
    fn scan_keys(&self, col: Column, prefix: &[u8]) -> impl Future<Output = Result<Vec<Vec<u8>>>> + Send;
    fn scan(&self, col: Column, prefix: &[u8]) -> impl Future<Output = Result<Vec<(Vec<u8>, Vec<u8>)>>> + Send;
}
}

Provided beside them, never overridden except by a multi-realm backend: realm(), key(parts), prefix(parts), get_json, put_json. Column is a hard partition — a scan in one never sees another’s keys — and it exists so a backend may treat kinds differently if it wants to.

Everything above is blanket-implemented:

KVStorage
  └─ TextSearch: KVStorage            BM25 over keys
       ├─ Agents                      blanket over KVStorage
       ├─ Skills                      blanket over KVStorage
       ├─ Harnesses                   blanket over KVStorage
       ├─ Memory                      blanket over KVStorage + TextSearch
       └─ Sessions                    blanket over KVStorage + TextSearch
Backend = the five, blanket

The cost of the blanket impls is that a backend cannot override a default with a native fast path — coherence forbids a specific impl where a blanket one exists. That is accepted: the alternative is one empty impl line per trait per backend, and no backend has yet wanted the override.

The keyspace

Agent    agent/{id}                              AgentConfig
         idx/agent/{name}                        id
Session  session/{handle}/meta                   SessionMeta
         session/{handle}/archive                memory entry name
         session/{handle}/msg/{idx:012}          HistoryEntry
         session/{handle}/evt/{idx:012}          EventLine
         idx/sess/{agent}/{by}/{created_at}/{h}  handle
Memory   memory/{name}                           MemoryEntry
Skill    skill/meta/{name}                       SkillSummary
         skill/body/{name}                       SKILL.md
Harness  image/{digest}                          ELF
         name/{name}                             digest
Config   default_agent                           id
Text     idx/text/{ix}/doc/{key}                 len, weight, terms
         idx/text/{ix}/term/{term}/{key}         term frequency
         idx/text/{ix}/stats                     doc count, total length

Ordering is load-bearing rather than incidental. created_at is RFC3339 and sorts lexicographically, so indexed_handles reads an agent’s sessions newest-last with no separate sort step. agent_ids reads ids straight out of the name index, already name-sorted, touching no configs. Message indices are zero-padded to twelve digits because keys sort as bytes and "10" would otherwise precede "2".

Two shapes appear, each chosen by its dominant access. A session’s keys nest under its handle so deleting one is a single prefix sweep. A skill’s metadata and body are separate keys so a listing reads names without touching markdown — that property is structural rather than a convention each backend must remember.

Writes are ordered content-first, index-second, so a crash orphans content nothing can reach rather than leaving an index entry pointing at nothing. Every index is rebuildable by scanning content.

Search

TextSearch is four operations that know nothing about what they index: a key, a string, and a number to weight by. A caller wanting a person’s own words to outrank a tool’s passes a larger weight, and what a “role” is stays in Sessions where it belongs.

The index is keys, as above. A document’s record names its own terms, which is what makes retraction cheap — dropping a document touches its own postings rather than walking the index. The predecessor in 0150 walked every posting list to delete one entry, so removing a five-hundred-message session was five hundred full-index walks.

A query term ending in * prefix-matches, which is free when terms are keys and is the nearest thing to stemming this design offers: the agent writes deployment process, later searches deploy*, and finds it. A prefix’s document frequency is the union it matches, so a broad prefix correctly weighs less than a precise term. Phrase search is deliberately absent — it needs positional postings on every write, and the tokenizer drops stopwords, so a phrase query would be quietly wrong rather than merely unsupported.

What may be indexed at all is decided by HistoryEntry::indexable: tool results and tool-call arguments are excluded because both carry credentials often enough that neither belongs in free text a query can reach, and a tool-calling assistant contributes only its function names.

crabdb

lib/crabdb is the shipped store. The format is CRMEM — inherited from 0150, which specified it for memory entries — generalised to opaque keys and values:

header   32 bytes, fixed, rewritable in place
         "CRMEM\0" | version u32 | flags u16 | reserved | index_at u64 | index_len u64
record   op u8 | col u8 | key_len u32 | key | val_len u32 | value
index    count u32 | repeated { col u8 | key_len u32 | key | offset u64 }

Records are appended and never edited; the newest record for a key wins. A resident BTreeMap<(col, key), offset> makes a lookup one seek and a prefix scan an ordered walk. The map holds offsets rather than values, so residency tracks how many keys exist rather than how much has been written — a four-megabyte harness image costs the same entry as a four-byte posting.

The header is fixed-size and the index is not, so the header holds a pointer and the snapshot lives wherever it last fit. On open the snapshot loads and only the records appended after it are replayed; a crash between checkpoints costs a short tail replay rather than lost writes, and a record torn by a crash ends the replay with the append position reset to the last clean boundary. Compaction rewrites live records to a sibling file and renames, so a crash during compaction costs the work and nothing else.

Durability is honest rather than maximal: writes reach the OS immediately, so a process crash loses nothing, and fsync happens on checkpoint and compaction, so a power loss can lose writes since the last one. This is what keeps posting writes cheap, and the text index writes many small records per message.

Realm

Every key carries a realm prefix. One realm is one store today, so it buys nothing — it is in the format from the first byte so that a backend serving many is a different KVStorage impl rather than a key migration, and so that a read outside the realm is inexpressible rather than merely forbidden. The word is deliberately not “tenant”: this is a runtime people install, and a solo user is not a tenant of anything.

Tunables

Ranking numbers are judgements, so they are asked for rather than fixed: Sessions::config() -> Weights carries role weights, title and summary boosts, and how many message matches to pull per requested hit; TextSearch::bm25() -> Bm25 carries k1 and b. Both have defaults, and because both traits are blanket-implemented the defaults are what every store gets today. When one genuinely needs to differ the hook belongs on TextSearch, which a backend implements directly and can therefore override.

What the runtime holds

Nothing derivable. Runtime has no agent registry and no memory handle: an agent is read from the store for the run that needs it, built, and dropped. Whether any of it is cached is the backend’s decision, which is what makes a different deployment a different implementation rather than a rewrite of the runtime.

The exception is a live session, which holds a steering channel. A channel cannot be persisted, so it is genuinely per-process state and stays.

The hook lifecycle becomes the harness lifecycle

0075 described a Hook trait in crates/runtime/src/hook.rs, a DaemonHook composite, and a crates/hooks crate holding skill, memory, mcp, os, delegate and ask_user. 0205 kept that seam deliberately internal — “internal hooks stay internal Rust” — while making harnesses the guest ABI. What it did not anticipate is that the internal seam would take the same name.

crates/hooks is deleted. The trait is Harness in crates/runtime/src/harness/, the composite is Hooks, and what used to be hooks are either harnesses proper (os, per 0205) or the two that remain internal because they wrap daemon state a guest cannot own (memory, mcp).

The lifecycle methods change for a reason that belongs to this RFC rather than to 0205. “Registered” is no longer a state an agent can be in — it is in the store or it is not — so on_register_agent / on_unregister_agent become on_resolve_agent / on_forget_agent. The first fires per run, for the agent that is running, and must be idempotent: there is no registry to call it once. The second fires when an agent is deleted from the store, because that is the only moment nothing will resolve the id again.

That inverts 0075’s stated invariant. It promised that by the time Runtime::agent() returned, hook state was in place, and that hook state was dropped the moment an agent became invisible — both properties of a registry with a membership boundary. What replaces it is narrower and cheaper: state is in place before the run that needs it, and is proportional to the agents actually working rather than to the agents that exist.

Alternatives

A KV primitive plus a SQL index. Tried and removed. The SQL half became twenty domain-named methods over four entity tables restating KV content, so a backend author still wrote per-entity SQL. A primitive is generic; a closed set of named domain queries is the domain model with a smaller surface.

A composite type pairing the primitives. Store<K, Q> was built and deleted. It forced a construction step, two generic parameters through every signature, and hand-written Arc<T> forwarding impls to satisfy its bounds — all of which vanish when the interfaces are implemented over the primitive directly and auto-deref does the rest.

A third-party embedded store. redb and parity-db both satisfy the requirements, and parity-db’s native columns match Column exactly. Rejected on dependency grounds: the bar here is “better than a directory of files,” which is a small enough target to own, and shipping a runtime should not mean shipping someone else’s storage engine.

Keeping SQLite. It satisfies every requirement, which is why it survived several rounds. What it does not survive is the question of what it is for: one table, three columns, no joins, no transactions.

An in-memory store with periodic flush. This is what 0150 did, and it is correct at the scale 0150 sized it for — hundreds to thousands of entries. It does not survive a long-running daemon whose keyspace includes an inverted index over every message ever written, both because RAM grows without bound and because a whole-file flush per write makes indexing a single message quadratic in the store.

Blobs stored separately from keys. Considered, since harness images are the only large values and are never scanned. Rejected once values live on disk rather than in RAM: a four-megabyte ELF and a two-hundred-byte agent config then differ only in length.

Unresolved Questions

  • index_text writes N posting keys, a document record and a counter update without atomicity. A crash mid-call leaves postings with no record to retract them, and concurrent writers drift the counter. Ranking degrades rather than breaking and the index is rebuildable, but no repair path is written and no KVStorage::batch exists.
  • Nothing calls checkpoint(). Where a daemon loop takes its durability points is undecided.
  • Search cost is unmeasured. Each query term is a prefix scan plus one document read per candidate — correct, and appropriate for a personal store, but it has moved from C to Rust and from one query to N reads without a profile.
  • Berm still reads harness images from the filesystem. The Harnesses interface exists and is unused; wiring it requires on_resolve_agent to become async.

Superseded RFCs

RFCs that have been replaced by newer designs. Kept for historical reference.

RFCTitleSuperseded by
0000Compaction0189 - Policy at the Edge
0038Memory0150 - Memory Store
0064Session0135 - Agent-First Protocol
0078Compact Session0135 - Agent-First Protocol
0080Cron0205 - Berm
0036Skill Loading0205 - Berm
0043Component System0205 - Berm
0171Topic Switching0185 - Session Search and Storage Primitives
0150Memory Store0207 - Store
0185Session Search and Storage Primitives0207 - Store
0075Hook0207 - Store
0203Client-Side Orchestration0205 - Berm

Reversed without a replacement

A decision can stop holding without another RFC arriving to say so. These are the ones a reader would otherwise take as current.

RFCWhat no longer holds
0205“ask_user stays a forwarded client tool, and so does delegate until it becomes a harness of its own.” Client-side tool forwarding was removed whole on 2026-08-18: SendMsg.tools, StreamMsg.tools, ToolCallForwardEvent and ReplyToTool are reserved field numbers, and there is no bridge behind them. A tool runs where the runtime does.