Cell types
The reference for the built-in cell types. One section per type: the params it takes, the messages it answers, the headers it sets, its error_codes and its failure modes.
Read it while you write or review a config.json. What a type is for and when to take it is on cells.md. The blocks around params are in config.md, the vocabulary in glossary.md.
The sections run in the order of the table in § Overview. Look up your type and skip the rest. What stands between here and that table holds for every type.
Where this file and meclaw-overview.md disagree, the overview wins: it is the single source of truth. Which types are live is this file, since which release is ../CHANGELOG.md, what is deferred is ../ROADMAP.md.
Every cell is registered in colony’s registry behind the same ActorHandle (an mpsc::Sender<Message>). What runs behind that mailbox depends on the class: one long-lived cell_task for a stateful cell, a stateless_dispatcher that spawns one short-lived worker per message for a stateless one (concurrency limit per cell via params.max_concurrency, unbounded by default), two Tokio tasks (handler plus I/O, over an internal mpsc) for a long-running one. The three are described in full in meclaw-overview.md, sections “Cell model”, “Stateless-cell dispatcher” and “Long-running cells: dual task”. Cell state is single-threaded from the view of its handler task, and Mutex, RwLock and atomics are forbidden in cell code.
Every I/O operation in cell code (HTTP, DB, subprocess, filesystem, MCP) is wrapped in its own tokio::time::timeout, concept A, the operation timeout. On Elapsed the cell emits a regular error message and ends handle() normally, with no restart. The value is configured per instance as params.external_timeout_ms, or under a name that fits the cell (query_timeout_ms for store). Behind it sits the substrate backstop cell.message_timeout, concept B, a coarse guard against cell hangs with no known cause. Details and the recommended defaults per cell type: meclaw-overview.md section “Timeouts”.
Cell emission modes (detail in meclaw-overview.md section “Cell emission modes w.r.t. messages[]”):
- atomic-emitting: the cell emits a fresh
messages[]carrying only its own contribution, with no pass-through. Every tool endpoint, every source and every LLM inference works this way. - stream-propagating: the incoming
messages[]is passed through and augmented with the cell’s own contribution. No built-in type does this; acodecell can. - script-determined (a special case): the emission mode follows from the script output, per execution. Only
code.
Overview
| Type | Task | Actor? | Emission mode |
|---|---|---|---|
hive | scope marker (authority and mutation boundary for a path prefix) and logical transit node in the routing graph | no, no actor, no mailbox, no cell.db | none (transit, no delivery) |
store | typed SQLite storage with schema and seed | yes, stateful | atomic-emitting |
llm | LLM inference, holds system state and blob cache | yes, stateful | atomic-emitting |
bash | shell execution (one-shot only) | stateless | atomic-emitting |
code | programmable body constructor (Python first) | yes, stateless (stateless dispatcher), a stateful code with cell.db deferred | script-determined |
web_fetch | HTTP client | stateless | atomic-emitting |
web_search | search-provider client | stateless | atomic-emitting |
file | filesystem CRUD with security boundary | stateless | atomic-emitting |
edit | file-editing operations | stateless | atomic-emitting |
proxy | external-chat bridge (Telegram first), dual task | yes, long-running | atomic-emitting (user turn per external message) |
timer | periodic event emitter, second-accurate, dual task | yes, long-running | atomic-emitting (schedule body) |
mcp | MCP-provider bridge, dual task | yes, long-running | atomic-emitting |
harness | agent harness (Claude Code) as a supervised child process | yes, stateful | long-running |
subcolony | child colony as one cell (opaque composition facade) | yes, long-running | atomic-emitting |
vault | sealed secret store, no operation returns a secret | yes, stateful | atomic-emitting |
web | mounted display substrate (SSR and LiveView diffs) | yes, long-running | dual task |
voice | real-time speech channel over WebSocket (audio in, turns out; text in, speech out) | yes, long-running | dual task |
ref has no row here. A cell.type: "ref" is a template-time type: at instantiation it places another template at its position and is gone afterwards (GH #277; details in config.md § Special case template reference). Every column of this table (Actor?, Emission mode) describes a runtime property that a type without a runtime does not have, so a row would have to invent two answers that do not exist.
Content transfer through the transfer body slot
This is a substrate slot rather than a cell-type feature: crates/meclaw-colony/src/db_transfer.rs, called in cell_task before handle(). It serves all eight types with their own cell.db (harness, llm, mcp, proxy, store, subcolony, timer, vault), with no per-type code and with no way for a cell type to shadow it. Two facts put it at that level: the seed loader was always generic (mutation::stage::apply_seed_jsonl), and the transfer runs on the cell’s own DbConn, because an FTS5 index built with meclaw_stem_v1 can only be written through a connection that has the tokenizer registered. See overview § Seed concept.
Input is the top-level body slot transfer, like the β params slot. A message carrying it does not reach handle().
{"operation": "export"}gives an inventory:{format, tables[]}.{"operation": "export", "table": "<t>", "key": ["<col>", …]}gives a document{format, table, key, schema, rows}. Uncapped (a truncated table lies about being one), ordered bykey(otherwise by every column), so the same content yields the same document.keyis optional and falls back to the table’s PRIMARY KEY. An export is a read. It discloses exactly what a read of the same table already discloses, so no write surface bounds it (store’swrite_surface).{"operation": "import", "table": "<t>", "schema": {…}, "key": [...], "rows": [...]}gives a receipt{format, table, rows_in_part, rows_written, rows_skipped}into a running cell.{"operation": "export", "to": "<dir>", …}gives the same document as a file: the slot writes<dir>/seed/<table>.jsonlitself and, last, the marker<dir>/seed/export_final.json. Withouttable, every content table goes into one directory;tablesnames the list and its order. Receipt{format, cell, exported_at, tables, rows, seed_dir}.{"operation": "import", "from": "<dir>", …}reads the same directory back, in ONE transaction over every table the call names (GH #261): a document applies whole or is refused whole, so a table the target refuses halfway down the walk leaves nothing behind either. Withouttable/tables, every<dir>/seed/*.jsonlin name order. Receipt{format, seed_dir, tables[{table, rows_in_part, rows_written, rows_skipped}], rows_written, rows_skipped}.keys(import only, GH #261):{"<table>": ["<col>", …]}, what makes a row THE SAME row, per table, for a call that names more than one.keybelongs to one table’s identity and therefore only travels when the call names one table, which left the whole-directory form unusable for exactly the cells that need it most: astoredeclares its tables inparams.schema, and that declaration cannot express aPRIMARY KEY, so every one of them falls back to an empty key and the import refuses the part by name. The way out was one call per table, and that trades away the property the one-call form exists for, because a malformed file halfway down the walk leaves every table before it standing.keyandkeystogether, an entry that is not an array of non-empty column names, and an entry naming a table the call does not address are each refused asinvalid_inputbefore anything is read.- The message form is the default rather than a fallback: when no path is named, no file is written and none is read.
Content and machinery are split. Transferable is every table of the cell.db except SQLite’s own sqlite_% bookkeeping, every virtual table together with its shadow tables (an FTS index is derived and is rebuilt by the triggers on import), and the three substrate tables last_input, meta, params. system is deliberately not excluded: the llm cell’s accumulated system.* tree is content, and seed/system.jsonl is a documented seed input (GH #99), so it has to be a documented output too. The default is therefore “everything but the substrate’s own”, exactly as the loader in the other direction writes whatever seed/*.jsonl names.
The three import decisions (overview § Seed concept carries the reasoning): the target wins every key collision; additive, never replacing; a partial import is a state rather than a failure (validation before the first write, writes in ONE transaction, re-applying idempotent). Values are written as they arrive. Re-deriving an identity is store’s canonicalize, and the seed loader has always behaved the same way.
Provenance survives structurally rather than through a list of names: the export projects every column, and the import refuses any part whose declared schema differs from the target by a single column, in either direction. audience_set, channel and speaker (0.16.0) are therefore non-optional payload without the substrate knowing those three names.
The slot touches two boundaries, and both hold (GH #260). The slot is answered before the consumes gate: consumes describes what handle() needs, and a transfer never reaches handle(). Otherwise every transfer sent to a cell with consumes.body.messages: required (which is every store in the library) would be dead-lettered before the slot was ever read. That position is right, and it had a consequence: store’s params.write_surface: "internal" is a cell-level check, and an import never reaches the cell. So since GH #260 the rule has a substrate-level equivalent. contract.write_surface: "internal" (see config.md § contract) bounds an import to senders inside the target cell’s parent scope, with the same scope arithmetic, the same fail-closed rule for a missing sender, and the same error_code write_denied. It is a provenance rule and nothing more: the substrate reads its own path and the sender, and nothing else, no params, no cell type, no look inside the document. What it explicitly does not refuse: an export (a read, and no write surface has ever bounded a read), an import into a cell that declares nothing (open is the default), an import from inside the cell’s own scope, and an import into a cell sitting directly under the colony root (whose parent scope is /, which contains every cell). Because the declaration lives in the contract block instead of in params, all eight cell types with a cell.db can set it, the store being only one of them. Because the two halves are not derived from one another, a cell that wants both sealed declares both. Beside them stands the wiring half of the same boundary as before (the hive port declaration, GH #133): a sender that may not address the store cannot send it a transfer either.
The slot writes files, and only inside its own fence (GH #555). The owner’s ruling on this is one sentence: “cells manage their own files, nobody else does.” When a call names a directory through to/from, the substrate writes or reads it, on the very path the cell is born from, because <dir>/seed/<table>.jsonl is exactly the format apply_seed_jsonl reads. The fence is a params declaration of the consuming cell: params.transfer.base_path (absolute, the precedent being file’s base_path, details in config.md), and to/from are relative to it. A cell without that declaration has no directory and falls back to none, so every named path is refused with transfer_path_out_of_bounds. The same code answers every path that climbs out of the fence: absolute, ../…, or resolving out through a symlink. The pre-check is lexical and runs before any filesystem call, so the answer is identical whether anything exists out there or not (the same closed oracle the file cell got in GH #107).
Writes land whole or not at all: every file is staged as <name>.part in the same directory, fsynced, and moved to its name with one rename(2); the marker is written last, so a reader waiting on export_final.json never finds a directory that is still filling. A .part is invisible to the seed loader, which takes only *.jsonl. On the way in, every part is parsed before the first one is applied, so one broken file in the set writes nothing at all. The receipt names the path as the caller wrote it, never the host prefix: a receipt travels further than the fence does. And the fence is not canonicalised and not checked for existence at boot (config.md). A directory that is not there yet becomes a transfer_io_error at the first to/from, and not one moment earlier.
There is one opt-out, and the cell declares it itself (GH #314). A cell whose contract says contract.transfer: "none" (config.md § contract) is exempt from this slot: its database does not travel, in either direction. The exemption covers export and import, because what may not leave may not be overwritten through the same seam either. It is answered before the arguments are read, with error_code: "transfer_exempt". A refusal that said one thing for vault_secrets and another for a made-up table name would itself be an inventory. The default is "all", and an absent key means "all": the substrate infers nothing from a cell type’s name, it reads a declaration. Why a declaration and not an exclusion list inside the substrate: a list of type names in db_transfer.rs would be invisible in the config.json of the cell it applies to, invisible in a diff, and would have to be edited again for the next cell type with the same need. Correction (GH #336, access@2.0.4): this used to read “exactly one type declares it today: the vault”. That is retracted, and it was only true for as long as the vault was the sole declarant. It is now two cell types across three shipped configs: the vault (§ below) in both templates (templates/vault, templates/access/vault) and the capability broker’s store (templates/access/store), whose grants are live bearer handles. An export is a read, which contract.write_surface does not bound, and migration there means re-granting at the target instead of importing. The exemption therefore visibly hangs on the declaration and not on a type name, which is exactly what this shape was built for.
error_codes live in the reply’s hop compartment; the header is as for store: operation, rows_affected, duration_ms.
| Code | When |
|---|---|
transfer_exempt | the target cell declares contract.transfer: "none", so its database is exempt from this slot. Applies to export and import, holds before the arguments are read, and names no table. Nothing was read, nothing was written |
unknown_table | the table does not exist, or exists but is substrate/derived and therefore not transferable (the text says which of the two) |
unknown_column | a key column does not exist |
import_schema_drift | the part’s schema does not match the target table’s columns; missing and extra ones are both named. The audience gate lives here. Nothing was written |
write_denied | the target cell declares contract.write_surface: "internal" and the sender lies outside its parent scope, or the message carries no sender at all (fail-closed). Applies to import only; an export is a read. Checked before the first write, so nothing was written |
transfer_path_out_of_bounds | a to/from does not stay inside the fence: it is absolute, climbs lexically above params.transfer.base_path, resolves out through a symlink, or the cell declares no params.transfer.base_path at all. The path is refused by name, never measured against its content; the answer is identical whether anything exists out there or not. Nothing was written and no directory appeared |
transfer_io_error | the fence is missing or is not a directory, or mkdir/write/fsync/rename/read failed. No marker was written, so the directory is not complete, and a reader trusts only a directory whose export_final.json exists. Tables that already finished may stand (each of them whole), and so may a leftover .part file, which is invisible to every reader. On the read side nothing was applied: every part is parsed before the first one is written |
transfer_seed_malformed | a seed/<table>.jsonl that was read has no header line, no schema object, or a data line that is not a JSON object. It names the file and the line number the way the seed loader does. Nothing was written, not from any other part of the same directory either |
sql_error | SQLite refused (constraint, type). The whole part is rolled back |
invalid_input | args-level fault: unknown operation, missing schema/rows, a row without a value in a key column, or a table without a PRIMARY KEY and without an explicit key (without one an import could only duplicate) |
hive, a scope marker and a transit node
A directory with config.json type: "hive" is the authority and mutation boundary for its path prefix. It is a scope marker with an additional transit role in the routing graph, and not a cell type in the ordinary sense. There is no hive task, no hive mailbox, no hive-owned cell.db and no ActorHandle entry in colony’s registry. Routing, lifecycle, mutation validation and UUID assignment run centrally through the colony. See meclaw-overview.md sections “Authority model” and “Concurrency and parallelism”.
In the DSL, directory nesting groups cells into a logical unit (/main/tool-loop/dispatcher, /main/tool-loop/collector). Mutations can use the hive path as a scope field. All diff operations within it are resolved relative to this path prefix, and colony rejects mutations whose paths would lie outside the scope.
The hive boundary is binding: an edge that crosses it has the hive as its endpoint, never a cell inside it. meclaw-overview.md § The hive boundary carries the whole rule with its reasoning, mechanics and migration state. Enforcement today is params.ports (§ below, GH #133), and the rule holds without the declaration too.
In routing a hive is transit, never delivery. From the sender’s view it is an addressable target; in the substrate it is a transit hop. When a message with target = <hive-path> arrives, colony does not deliver it into a mailbox, because there is none. Instead it evaluates the hive’s out-edges (EdgeTable entries with from = <hive-path>) as part of its single routing layer: CEL condition against headers, apply modifier, one regular routing hop per match to the respective to path, TTL decremented per hop. No hive-owned evaluator, no separate routing logic. See meclaw-overview.md section “Hive paths as target: transit evaluation”. With no matching out-edge the message becomes a dead letter with error_code = "hive_no_route". Graph reads for a hive scope run over /colony/graph?scope=<hive_path> (see meclaw-overview.md section “Visibility / read paths”).
Whether a hive is active is decided exclusively by external edges: exactly one endpoint lies in the unit (the hive path or any descendant), the other outside it. Its internal wiring does not count, and that includes the form mandated above, {"from": ".", "to": "./<cell>"}, which names the hive path itself. It wires the hive’s inside, not the hive (GH #265; see meclaw-overview.md § Connectivity and activity, hive sharpening). A disconnected hive deactivates its entire subtree. This is what makes hives the attachment point for complex templates: an instantiated subtree template is attached to its hive path via edges, and the attacher does not need to know the internal structure.
params takes exactly four keys, graph, ports, required_drains and contract. The HiveParams deserializer is deny_unknown_fields, so any other key is a boot error.
-
graph(optional): initial desired graph for the subtree (format seemeclaw-overview.mdsection “Graph schema”). Colony reads this at filesystem bootstrap and enters the declared cells into the registry and the edges intocolony.db. After the first bootstrap, the persisted edge table incolony.dbis the truth, andparams.graphis only an initial hint. -
ports(optional, GH #133): array of the endpoints a parent is meant to wire. An entry has one of two forms: the short name of a direct child as a string ("stamp"), or the slot form as an object ({"name": "gen", "slot": true, "unbound": "park"}, GH #285, see Slots below). Both forms name a direct child and are the same thing to the port boundary; the second additionally says that this address may stand empty, and what happens to a message that reaches it while it does. The key is opt-in, and its presence is the switch. Without it nothing changes: every interior node may be wired from anywhere, which is the behaviour every topology shipped before this field. With it the hive scope is sealed and colony’s mutation validation rejects anadd_edgesendpoint that reaches past the port (error_code: "hive_port_boundary", pre-destructive, so nothing is staged, spawned or wired). What stays legal: an edge between two nodes inside the hive (any depth), an edge onto the hive path itself (the transit address), an edge onto a declared port, and the hive marker wiring its own children. What is rejected: an edge that pairs an interior non-port node with an endpoint outside the hive, in either direction, because a reply lane wired straight out of an interior cell bypasses the port exactly as an inbound lane does. An empty list is legal and means “the hive path is the only address”. Two deliberate limits: the check covers a mutation’sadd_edgesand not the bootstrap (see below), and a port is a direct child, so a node below a port is not the port.Why the seal does not cover the bootstrap (ruling 2026-08-15): the birth topology is the sovereign design of the colony author. Whoever writes the
params.graphof a parent scope is describing the colony they intend, with the whole tree in front of them. That is authorship, not a breach. The seal guards against what happens afterwards: a runtime mutation, possibly written by a model, that reaches into a hive it did not build. So aparams.graphmay legitimately wire a deep endpoint into a sealed hive at boot, and several shipped topologies do exactly that. Boot-time enforcement is not ruled out forever, though it would arrive as its own opt-in switch, never by silently widening this one, because that would retroactively invalidate birth topologies that are correct today.Slots are ports that may stand empty (GH #285): the object form declares an address before anything stands at it.
"ports": ["brief", {"name": "gen", "slot": true, "unbound": "park"}]"slot": trueis mandatory, because the object form exists for slots only and a plain port stays the string.unboundis mandatory too: whoever announces an empty address must say what happens to a message that reaches it while nothing is bound behind it. There are exactly three words, and any other one is a boot error that enumerates the three:drop: the message is discarded. No dead letter, no error, because the hive said the absence is normal.error: the message is dead-lettered witherror_code: "slot_unbound";resolved_targetis the slot address<hive-path>/<slot-name>(seemeclaw-overview.md§ Behavior on routing errors).park: the message is held and released once something is bound at the address (details below).
The declaration buys exactly two exemptions. A slot is no cell: it stands in no registry, has no
cell.dband is no node. It is an address with a promise, and the promise exempts it from exactly two checks that would otherwise treat an empty address as a typo:- The dangling endpoint at boot. An edge onto a declared slot is not an unresolvable endpoint, so the boot commits and
--validate-strictstays green. A path that is not declared as a slot and has no occupant stays exactly what it is today: a hard error under--validate-strict. A plain port does not buy this exemption; only the slot form is a statement about emptiness. edge_schemaat mutation time.add_edgesonto a declared slot commits before it is filled; the same operation onto an undeclared empty address of the same hive staysedge_schema.
What a slot is not: it is a valid
add_edgesendpoint, and never a target ofremove_nodesorswap_nodes[].match. Both answermatch_no_hit, because there is no node for them to hit. The combined diff is allowed, and it is precisely the movement slots exist for: removing a slot’s occupant and wiring the address in the same diff commits, because the declaration outlives its occupant. What remains is the declared empty slot.Slots live on sealed hives. The slot declaration is collected by the same reader as the port boundary, and that reader skips the root scope (
/): nothing encloses it, so sealing it would be vacuous. A slot declared there therefore buys neither exemption. Its edge still dangles, and no warning says so. Slots belong in a hive below the root.parkin detail: the queue is FIFO and bounded per slot bycolony.json slot_park_max(default 64, seemeclaw-overview.md§colony.json). At the bound the newest arrival is dead-lettered asslot_park_overflowand the held ones are untouched, because the beginning of a history is the part a later reader cannot reconstruct. Release happens on binding: as soon as a cell or a hive stands at the address, the held messages go out in emission order, ahead of anything the caller sends after the binding. A colony shutdown discards whatever is still parked, because the queue lives in the colony task and not on disk. A colony that declares manyparkslots and fills none therefore holds up toslots × slot_park_maxmessages for as long as it runs. That is the declared semantics (“hold it”), andslot_park_maxis the lever against it.The declaration has a limit.
unboundgoverns deliveries over an edge, meaning an out-edge decision and the transit over a hive’s out-edges. A message that addresses the slot path directly from outside (over the HTTP API, say) does not reach the declaration: to it the address is unoccupied, and it ends asunresolved_pathexactly as before. -
required_drains(optional, GH #147/#237): array of{port, hop, because}or{accepts, emits, because}, pairs that belong together. The port form reads as: if anything outside this hive is wired toport, thenportmust have an edge that carries a message with hophopout of the hive. The classic case is an ingress whose refusals leave on a reject egress: with no consumer, the refusal is a dead end and nobody ever learns the work was not done. Opt-in likeports, so without the key everything stays as it was.A mutation that breaks the pairing is rejected pre-destructively with
error_code: "required_drain_missing", and the rejection carries the hive’s ownbecausesentence verbatim, because a refusal that cannot say what it protects is one people route around. Wiring both edges in the SAME mutation is explicitly the intended answer, and the check runs against the post-state precisely so that it is.The check works by sending the described hop through the real edge conditions (
apply_edges, the same function that routes at runtime) instead of by comparing condition text.hop.route=='reject',hop.route in ['reject','error']andhop.route != 'bundle'are all three correct drains, and a string comparison would call two of them broken. An edge that stays inside the hive does not count: the refusal has to leave. The rule says nothing about the destination. Whether the drain is a good one is the parent’s business; whether one exists is not.The bootstrap is warned, never refused, for the same reason the port seal leaves the boot alone: the birth topology is authorship. A tree that has been running for weeks is not stopped from starting. It says the sentence and carries on.
"params": { "ports": ["brief", "gate"], "graph": { "edges": [ … ] }, "required_drains": [ { "port": "gate", "hop": { "route": "reject" }, "because": "a refused input leaves the hive here" } ] }The lane form (GH #237): the form above names a port, and a sealed hive (
"ports": []) has none. From outside,<hive>/<cell>is not an address any more, soportcan never be wired again and the declaration can never fire. A rule that cannot fire reads exactly like one that can, so the same obligation exists in the vocabulary the boundary leaves standing:"params": { "ports": [], "contract": { … }, "required_drains": [ { "accepts": "in_remember", "emits": "reject", "because": "a refused block leaves the hive on this lane" } ] }It reads as: a caller that sends me
in_remembermust subscribe toreject. Both names are lanes of the hive’s ownparams.contract; an entry naming a lane the hive does not have is dropped by the reader with a warning, for the same reason a deep port name is dropped. The obligation is triggered by the caller’s own edge: an edge from outside onto the hive path whoseset_hop.routeconstantly names theacceptslane. An edge whose lane is only knowable at runtime ('in_' + hop.kind) names none here and triggers nothing, which is the same conservatism the contract check is built on.What the lane form cannot see: whether the drain exists is a statement about the caller’s subscription, which GH #173 deliberately leaves unchecked, because shipped topologies tell lanes apart by a second hop key that a route-only probe does not carry. So the probe decides only what it can. The router carries the lane out (drained), the condition fails to evaluate because it reads a key the probe does not have (unknown, counts as drained), an edge without a condition takes everything (drained), and only when every out-edge evaluates cleanly to
falseis the mutation refused. The residue is a subscription that guards its extra keys withhas(): that yields a cleanfalseand would be refused. That refusal is a limit of the probe, so give the lane an edge of its own. -
contract(optional, GH #173):{accepts, emits}, the hive’s contract as a list of lanes (hop.routevalues) instead of prose. Full description and enforcement table inconfig.md§params.contract. In short: a mutation edge onto the hive path whoseset_hop.routeis constant must name anacceptslane, everyacceptslane must have a door inward, and everyemitslane must lead back out through the hive path. Otherwiseerror_code: "hive_contract", pre-destructive. Opt-in likeports, checked with the sameapply_edgesasrequired_drains, and the boot only warns."params": { "ports": [], "graph": { "edges": [ … ] }, "contract": { "accepts": [ { "route": "in_batch", "context": ["session_id"], "because": "one closed session as a single write batch" } ], "emits": [ { "route": "episode", "because": "one message per turn of the batch" } ] } }
There is no scope-owned dead_letters override: the dead-letter queue is always /colony/dead_letters, because a hive is the authority and mutation boundary and not the DLQ boundary. Otherwise a hive owns no fields of its own, and in particular no routing configuration, no mailbox size and no emission-mode statement. Hives have no actor and no mailbox; their routing role is passive transit evaluation by colony over the params.graph edges.
store, typed persistent storage
A CRUD cell with its own cell.db. Schema and column types can be declared in params.schema, and the cell creates the tables from it. It can also create a new table per message. Table and column names pass a syntax gate: [A-Za-z_][A-Za-z0-9_]{0,62}, no sqlite_ prefix, no _fts suffix. The only strings ever formatted into SQL are what the SQLite catalog (sqlite_master/pragma_table_info) itself returned, or values from an internal enum. Caller text reaches statements exclusively as bind parameters.
Emission mode: atomic-emitting. One response message per query message, with the result as a turn, and that promise stands unchanged even when the query message carries more than one operation. A body with N tool_call turns (GH #295) is answered with one message carrying N tool_result turns in call order. What is counted are tool_call turns, not entries in messages[]. On the bundle path (two or more tool_call turns) anything that is not a tool_call is skipped, because the prose an llm cell puts beside its calls is not an operation, and every tool_call is answered. The single-operation path skips nothing: it reads messages[0] strictly, so a body [text, tool_call] is still refused there with invalid_input, frozen behaviour that GH #295 left untouched. At N == 1 nothing changes at all, and the reply is the same as before, byte for byte, without results[] and without bundle_errors. A body with no tool_call at all remains the familiar invalid_input refusal.
Three things a bundle does not promise. Atomicity: the operations run one after another over the one DbConn, each on its own, and a failed one does not stop those behind it. Rollback: what operation 1 wrote stays written even when operation 2 fails, because a bundle is not a transaction. A dependent chain: the args of every operation are fixed before the first one runs, so if operation 2’s where is operation 1’s result, that is not bundleable and belongs in two messages.
Where a bundle’s metadata lives. The turns stay schema-pure: origin, type, text, id, the four keys a tool_result needs. Beyond them $defs/TurnObject in ubf-body.json allows only happened_at (GH #135) and nothing else (additionalProperties: false), and a turn carrying a key outside that set would dead-letter the whole reply as InvalidUbfBody. The per-operation metadata therefore travels in the store-specific top-level slot results[]: one entry per operation, in call order, carrying tool_call_id (the correlation key to its turn), operation, rows_affected, duration_ms and error_code if that one operation failed. The headers describe the bundle as a whole: operation: "bundle", rows_affected as the raw sum over the operations, duration_ms as the total, and bundle_errors as the number of operations carrying an error_code. That sum mixes reads and writes (a select over 3 rows plus an insert of 1 row makes 4), and the load-bearing per-operation number sits in results[].
A reader learns that one operation of the bundle failed from a single header read, hop.bundle_errors > 0. That is exactly the property an edge needs that would otherwise condition on hop.error_code (GH #343): the routing decision is taken at the header, without opening the body, and which operation it was and with which code follows from results[]. bundle_errors is present on every bundle reply, 0 included (checked-and-clean is not the same as nobody-counted); on a single-operation reply the key is never there. The header’s own error_code keeps its hard meaning, that the whole reply is a refusal carrying no result, and it never signals a partial failure. One exception, and it is no partial failure: if one operation’s tool_call carries a text that is not JSON, the whole message is refused with invalid_input, with no results[] and no bundle_errors. For that one operation the caller’s intent is unknown, and a bundle answers per position, so there would be nothing honest to put in its slot. A guard loses nothing by it, because such a reply carries hop.error_code, which it reads anyway.
A bundle’s timeout budget: query_timeout_ms (concept A) applies per operation and not per message, so a bundle of N operations may take up to N × query_timeout_ms of wall-clock time. An operation that runs out reports its query_timeout in its own results[] entry, after which the remaining ones take their turn. cell.message_timeout (concept B, the substrate backstop; meclaw-overview.md § Timeouts) has to be sized accordingly: left dimensioned for a single operation, the backstop fires before A can produce a clean error message.
Input format (analogous to bash): structured JSON args in the tool_call turn. The mandatory field operation takes one of "insert", "select", "update", "delete", "create_table", "search", "traverse", "similar", "set_alias", "canonicalize", "alias_candidates", "reject_pair", alongside table and the operation-specific fields:
insert:row(object{ "<column>": <value> }).select:columns(mandatory, an array of column names with at least one entry, the projection) plus optionalwhere,order_by(array of{ "col": "<column>", "dir": "asc"|"desc" }, multi-column) andlimit(integer >= 1, with no implicit default and no cap, since the runaway guard isquery_timeout_ms), plusdistinct(bool, defaultfalse, GH #68).distinctdeduplicates the projection: two rows that agree on every requested column are one answer, and alimitthen counts answers instead of rows. That lets the store settle a set question (which values does this column combination carry?) where the rows are, instead of shipping all of them over the mailbox first. Underdistincteveryorder_bycolumn has to be projected, otherwiseinvalid_input: SQLite accepts the other form and sorts by a value the deduplicated rows disagree on, so which row survives and where it lands would be unspecified, and a prefix of an unspecified order is not one. The cell has no projectionlessSELECT *: ifcolumnsis missing or empty, the cell answers withfinish_reason: "error"anderror_code: "invalid_input"(no cell crash; doc-to-code correction, ruling 2026-08-08). The result is an array of row objects, projected onto the requested columns.update:set(object) plus optionalwhere.delete: optionalwhere.create_table:columnsas a 2-level map{ "<column>": "<type>" }(typestext/int/json), and not asschema.search:match(mandatory, FTS5 query syntax) pluscolumns(mandatory, as inselect) plus optionalwhere/order_by/limit. Since 0.2.0 thematchtext runs through the same stemming tokenizer as the index (seeparams.fts), so search term and index term are folded the same way. Only on tables with aparams.ftsdeclaration, otherwiseinvalid_input. Every result row additionally carries arankcolumn (bm25, smaller is better); withoutorder_by,rankis the default ordering.set_alias(0.2.0):aliasandcanonical(both mandatory, non-empty) plus optionalrecorded_atandcolumn. Writes into the alias table oftable’sparams.canonicalbinding, as an upsert onalias, so calling it again with the same alias is a correction and not a second row.tablenames the bound table (facts, say), never the alias table.columnnames thesourcecolumn of the binding meant, and is mandatory as soon as the table carries more than one binding (otherwiseinvalid_input), because an alias is a statement about EXACTLY one identity dimension. Without a binding the op isinvalid_input. Resolution is one hop and never transitive: whoever writes an alias writes it already resolved. Under a normalising binding,aliasandcanonicalare stored in their normal form, so one judgement covers every spelling that differs only in case, whitespace or Unicode composition.canonicalize(0.2.0): re-derives the bindings’ target columns for every row from the original plus the alias table. Optionalcolumn(0.2.0) narrows it to one dimension; withoutcolumnevery binding of the table runs.rows_affectedcounts only the rows whose value actually changed, summed over the dimensions, so a second run over unchanged data reports 0. This is also the revert path: remove the alias row withdelete, runcanonicalize, and every row falls back onto its untouched original.alias_candidates(0.2.0): returns candidate pairs of similar values of a binding’s derived column, the feed of the nightly GC. Args:column(mandatory as soon as the table carries more than one binding) plus optionallimit(default 20),min_score(0.0to1.0, default0.5) andmax_values(default 500, cap 5000, the comparison set, quadratic in runtime). The result is an array of{ left, right, score }, sorted byscoredescending and then alphabetically, hence stable across runs. The score is a trigram Dice coefficient over the normal form of both sides (hand-built, no extension). Pairs that are already settled are excluded, in both directions: accepted ones (both sides point at one identity through the alias table) and, since 0.2.0, refused ones (the pair sits in the binding’srejectedtable). Otherwise the GC would propose the same settled pairs every night and pay a top-tier model again for every refusal it already made. The op merges nothing: it reads, scores and sorts. The judgement is the GC’s, and what it persists is an ordinaryset_alias.reject_pair(0.2.0): remembers a judgement’s No, that two candidates are NOT the same identity. Args:leftandright(both mandatory, non-empty, not equal) plus optionalrecorded_atandcolumn(mandatory as soon as the table carries more than one binding). Writes into the binding’srejectedtable, as an upsert on the pair, so re-judging is a correction and not a second row. The pair is unordered: both sides go through the same key an alias does (under a normalising binding, therefore the normal form) and are stored in a fixed order, so(a,b)and(b,a)are one row. Withoutparams.canonical.rejectedthe op isinvalid_input, since there would be nowhere for the refusal to live. Effect:alias_candidatesstops proposing the pair. The revert is the ordinarydeleteon that table, after which the pair is a question again. The op touches no row of the bound table.traverse: multi-hop over an edge table via a recursive CTE, directed fromsrctodst. Args:tableplus the column rolessrc/dst(optionalkind/weight, all catalog-validated),start(bind value), optionalwhere(full operator set, applied per edge) andcolumns(additional edge columns in the path rows), guardsmax_depth(default 2, cap 5) andmax_nodes(default 200, cap 5000). Values above the cap are rejected withinvalid_input, never silently clamped. Cycle elimination runs per path including the start node, so an edge back to the origin is pruned. The result is an object payload{ paths, truncated, max_depth, max_nodes }, and every path row carries end node, depth, path array, edge attributes and accumulated weight. There is noorder_by(BFS-style expansion, and the order within one depth is not part of the contract);truncated: truemakes themax_nodescutoff visible.similar: similarity ranking over a vector column via the registeredhamming()scalar function. Args:table, vector column, query vector (bind), optionalwhere/order_by/limit,columns(which must not containdistance). Every result row carriesdistance(smaller is better); the default ordering isdistanceascending with arowidtiebreaker. Vectors are Base64 TEXT (primary; real BLOBs are additionally accepted, and a native blob write path is a roadmap defer), strict Base64 (reject on alphabet, padding and length errors), andNULLstaysNULL. A length mismatch between two vectors is a loudsql_error, because a mismatch is almost always a breach of the embedding-generation discipline and never a silent skip. The op always implicitly adds<vector column> IS NOT NULL, sinceNULLembeddings (the backfill queue) would otherwise rank first. Known limits: no enforced model equality (the caller filtersmodel_iditself), and no ANN index, so a full scan over the filtered set.
columns therefore has a different form depending on the operation: with select an array of column names (the projection), with create_table a 2-level type map. where takes per column either a bare value (shorthand for eq) or an operator object with exactly one key out of eq, neq, lt, lte, gt, gte, in (array), is_null (bool), or_null (wrapping exactly one comparison operator, depth 1). An object with an unknown key is invalid_input. The operator forms apply uniformly to select/search/update/delete (one shared build_where path). schema is exclusively the params block (bootstrap tables). Only tool_call turns are accepted; direct use with user/system origin (see below) remains a limitation.
Two properties of a read are frozen, so that a topology can rely on them. limit without order_by returns an unspecified selection: SQLite is free to return any rows in any order, and a re-run on unchanged data may answer differently. A prefix of an unordered set is not a page, so whoever pages, sorts. And select without limit is uncapped: no implicit default, no hidden ceiling, so a growing table eventually answers with the whole table over one mailbox. The only guard is query_timeout_ms, which bounds the time and not the row count. Both are deliberate, because a silent cap would turn a complete answer into a truncated one without saying so, and both stay that way.
Body format of the response: messages[] with a single turn. In tool-loop use typically { origin: "tool", type: "tool_result", text: "<json-serialized result>", id: "<tool_call_id>" }. In direct use outside a tool loop the origin may also be user or system, depending on the application convention, and id is then omitted. A bundle reply (N > 1, see Emission mode) carries N such turns plus the top-level slot results[].
Output header (hop compartment, expires on the next cell emission): operation, rows_affected, duration_ms, optional error_code. operation is present on every reply, the error surface included (invalid_input, query_timeout, write_denied, GH #331): it carries the refused op, or the literal error when nothing parseable arrived. An edge that conditions on hop.operation therefore does not lose exactly the replies that report a failure. A bundle reply (see Emission mode) stamps operation: "bundle" and additionally carries bundle_errors.
Failure classification (analogous to bash) has two families (doc-to-code alignment, GH #109):
- SQL level gives a regular
tool_resultturn withheader.error_code("sql_error"/"unknown_table"/"unknown_column"/"type_mismatch"/"constraint_violation") and nofinish_reason: "error". These are the failures the query finds while running: constraint violation, type mismatch, unknown table or column. The rationale is that the LLM or caller reads the code and decides (retry, schema correction, different operation). - Args level gives an error message WITH
finish_reason: "error". Three codes:"write_denied"(GH #132, the store declareswrite_surface: "internal"and the sender lies outside the owning hive scope; the op is refused before it reaches the database, and unlike the two below the refusal keeps thetool_call_idso it stays correlatable in a tool loop),"invalid_input"(malformed body, notool_callturn,textthat is not JSON, unknown operation, missing projection, unknown operator key, guard violation, rejectedparamsupdate) and"query_timeout"(query_timeout_msinterrupted the running query). This class either never reaches the database or is aborted halfway, so there is no result atool_resultcould report on. The earlier wording sortedinvalid_inputandquery_timeoutinto family 1; that was the documentation and never the code (the doc comment instore/cell.rshas always described the split). Pinned incrates/meclaw-cells/tests/fitness_store.rs. As-built detail: the turn of aninvalid_input/query_timeoutrejection carries an emptyid, so such an answer cannot be correlated bytool_call_idinside a tool loop, only by ordering.write_deniedis the exception, decided after thetool_callhas been parsed, so theidis known and travels back.
Only internal errors (DB corruption, spawn error) trigger a cell crash and restart. unknown_column also covers select/where/order_by, not only the insert path. The traverse/similar failure cases map onto the existing codes, with invalid_input for guard and arg violations, unknown_table/unknown_column via the catalog, sql_error for a vector mismatch and query_timeout. No new code.
params:
schema: 2-level map{ "<table>": { "<column>": "<type>" } }with typestext/int/json. Constraints (PK, NOT NULL, UNIQUE, default, index) are deferred and need a separate design pass. An existing table is grown into the declaration at spawn: every declared column it lacks is added viaALTER TABLE ADD COLUMN(0.2.0), becauseCREATE TABLE IF NOT EXISTSalone is a no-op on an existing table and an existingcell.dbwould otherwise silently carry one column less than the running code reads. Strictly additive: an existing column the declaration does not name is never touched, never retyped, never dropped (no-delete).canonical(0.2.0): map{ "<table>": [ { "source": "<column>", "target": "<column>", "aliases": "<table>", "normalize": <bool>, "rejected": "<table>" }, … ] }, which declares one column the derived identity of another. A table may carry several bindings (0.2.0: a fact has two identity dimensions, the relation and the entity it is about); a bare object instead of the list is read as a list of one, so aconfig.jsonwritten for 0.2.0 keeps parsing unchanged.sourceis the written column (which stays byte-identical),targetthe store-owned derived column,aliasesthe mapping table the store creates itself (aliasPRIMARY KEY tocanonical, plusrecorded_at). Effect:insertandupdatefilltargetfrom the alias table on every write (a caller-suppliedtargetvalue is dropped, because the column belongs to the store), and at spawn the store creates the alias table and backfills emptytargetvalues once (the same catch-up property the FTS index has).sourceandtargetmust be differenttextcolumns of the declared table, andaliasesa syntactically valid name that is not inparams.schema. Two bindings of one table have to stay independent: the samesource, the sametarget, atargetthat is another binding’ssource, or a shared alias table (across tables as well) are declaration errors.normalize(defaultfalse, 0.2.0) enables the only automatic merge the store performs: values are put into their normal form before lookup and storage (Unicode composition, case fold, whitespace collapse), so two spellings with the same normal form are ONE identity from the mint on, provable instead of judged. Anything beyond that (typos, edit distance) the store never merges itself; it reports it throughalias_candidates. Normalisation composes the Latin-1 Supplement marks, and a mark it does not cover leaves both spellings different. The price is a missed merge, never a wrong one.rejected(optional, 0.2.0) names a second store-owned table, the memory of a judgement’s No (left_value,right_valueas PRIMARY KEY, plusrecorded_at). The alias table cannot carry it, because itscanonicalisNOT NULLand a NULL there would read as “resolves to nothing”. It is created additively at spawn, filled byreject_pairand excluded byalias_candidates; without it the store behaves as it did before therejectedtable existed. It is held to the same rules asaliases: syntactically valid, not inparams.schema, never shared with another binding. Immutable likeschema. Both store-owned tables have their key asserted at spawn instead of assumed (GH #255): if one of them is already standing without it (the JSONL seeder builds a table from the header line alone, without constraints, and it does so at instantiation time) it is rebuilt with the key. Every row comes along, duplicates collapse onto the key (the most recentlyrecorded_atrow wins), and an undeclared column is carried over. Without that rebuildset_aliasandreject_pairwould be SQL errors instead of upserts, because SQLite refuses anON CONFLICTwhose target matches no key, and the judgement would never be written.fts: map{ "<table>": ["<column>", …] }, which enables an FTS5 full-text index (external-content table plus triggers) over the listed columns. Only tables fromparams.schema, onlytext/jsoncolumns, and no FTS for tables created viacreate_table(a known limit). Immutable likeschema. Existingcell.dbs build the index once on the next spawn, including rows written before the declaration. If the declaration drifts from the columns of the existing index, the shape of the drift decides. If the existing columns are a proper prefix of the declared ones (purely additive drift, appended at the end), the index and its three triggers are dropped and rebuilt from the base table. The triggers have to go along, becauseCREATE TRIGGER IF NOT EXISTSwould otherwise keep the old column list alive and rows written after the migration would never reach the new column. Second exception (0.2.0): if the declared list arises from the existing one by replacing aparams.canonicalbinding’ssourcewith itstarget(canonical drift), it is rebuilt as well, which is exactly the migration by which the keyword leg moves from the written spelling to its canonical twin. The two classes compose (0.2.0): the substitution is applied first and the additive rule then runs over its result, so a store that skipped a release migrates in ONE wake instead of being refused. Every other column drift (column removed, reordered) stays a loud spawn error.- Stemming (0.2.0): every FTS index is declared with the store’s own FTS5 tokenizer
meclaw_stem_v1(tokenize='meclaw_stem_v1'). The tokenizer is a wrapper aroundunicode61, so splitting text into words, case folding and diacritic removal stay there; what is added is a conservative light stemmer over the individual token. FTS5 runs a table’s tokenizer over the indexed text and over the query text, which is how a plural and a singular meet on one term: since 0.2.0"lieblingseditoren"*reaches the index termlieblingseditor, which was impossible before, because FTS5 can only prefix-match at the end of a word. Two steps, each firing at most once, on the already case- and diacritic-folded token, with a minimum stem of 3 characters: (1)-swhen the preceding character is one ofb d f g h k l m n r t w(English plural, German genitive; the guard keepshaus,atlas,bonuswhole); (2)-ern(>5 characters), else-em/-en/-er/-es(>4 characters), else-e(>3 characters). No Snowball, no derivational morphology, no umlaut expansion, and the restraint is deliberate (over-stemming on German compounds). Migration: a third drift class next to additive and canonical, where an existing index that does not declare the tokenizer is dropped and rebuilt through it. This happens automatically on the next spawn, with no tool and no manual step. The name is versioned (_v1) precisely because it is the only migration signal an index carries: a change to the stemming rules bumps the suffix, which turns the rebuild on for every existing index, and the previous spelling is kept registered on the connection so the old index stays openable long enough to be dropped. The tokenizer is connection-bound: it lives on the SQLite connection and not in the file, so a connection without it cannot even open an index that declares it. The substrate registers it on every birth path of astoreconnection (wake, respawn,DbConnre-open); an external tool that opens thecell.dbdirectly cannot read the<t>_ftstable, and the base tables are unaffected. write_surface(optional, GH #132):"open"(the default, and what an absent key means) or"internal". An opt-in writer boundary."open"is the historical behaviour, where whoever is wired to the store’s port may write."internal"declares the write surface internal to the owning hive scope, which is the store’s own parent path: a write op whose sender lies outside that scope is refused at the cell withfinish_reason: "error"anderror_code: "write_denied", before anything reaches the database. Reads stay free from anywhere, soselect,search,traverse,similarandalias_candidatesare never bounded. Bounded areinsert,update,delete,create_table,set_alias,reject_pair,canonicalizeand the βparamsslot (which persists intocell.db, so it is a write by the same definition). The sender is the one the colony stamps (reply_to) and never a body field, because an identity a caller could write into the body is not an identity; a message without a sender (a source message from an ingress or an event) is outside the scope, fail-closed.write_surfaceis immutable, because a boundary a message can switch off is not a boundary. Known limit: a store sitting directly under the colony root has/as its owning scope, which contains every cell, so the declaration is then inert and the store logs a warning at spawn saying so. Enforcement is at the cell, because the substrate’s edges carry no read/write distinction (the op travels in the message, not in the edge); the wiring-level half of the same boundary is the hive port declaration (params.ports, GH #133), which stops an outside cell from addressing the store at all. This declaration bounds whathandle()does, and it does not reach what the substrate does beforehandle()(thetransferslot’simport, § Content transfer). That is what the type-neutral siblingcontract.write_surfaceis for (GH #260,config.md§ contract), and a store meant to be sealed against foreign writers declares both.query_timeout_ms(concept A, see overview § Timeouts): per-query enforced timeout viaDbConn’sInterruptHandle. It demonstrably also interrupts a running recursive CTE (traverse).- Optional seed data (convention path
seed/<table>.jsonl). The seed takes effect only onOpenStatus::Createdof thecell.db(see overview § Seed concept). - Rows into a RUNNING instance arrive two ways: as a message to the store (
insert, the ordinary path for data) or as the diff operationseed_rowsat/colony/mutations(GH #456, the path for rows that are permissions and keys, with a digest, an access verdict and amutation_logrow). The mutation door writes intocell.dbdirectly, and checks against this store’s declaration while it does: only tables fromparams.schema, only declared columns, and a row already present column for column is not written a second time.write_surfacedoes not bound it, because that key binds messages and not the write authority (see overview § Mutation operations).
Runtime param updates (β, config.md § Access L.20) work as for llm (see there): a top-level params body slot, partial, last-write-wins, persisted in the cell.db, replayed over the birth params on wake or respawn. Mutable: query_timeout_ms, which takes effect immediately live, since the running DbConn adopts the new A-timeout for the next query without a wake or respawn. Immutable per store: schema, fts and canonical (bootstrap-only, baked into the cell.db via DDL at spawn, so a runtime change would desynchronize the live tables from the declared schema), and write_surface (GH #132, since a boundary a message can switch off is not a boundary). An update attempt on one of these or on an unknown key is a loud reject (error_code: "invalid_input"), with no partial apply. Under write_surface: "internal" the params slot is itself a write (it persists into cell.db), so an update from outside the owning hive scope is refused with write_denied before the merge, and not even an overlay is left behind.
llm, inference through a provider adapter
A bridge to an LLM provider. It consumes and emits universal body format (see meclaw-overview.md section “Body format (universal)”). There is no inner loop: exactly one provider call per inference message. Iteration (tool loops, ReAct, plan-and-execute) arises through topology.
Emission mode: atomic-emitting. Per inference call the llm cell emits exactly one new assistant turn, and the incoming messages[] is not passed through. Whoever wants to hold the conversation thread together across several steps builds that via topology, for instance a memory hive in front of the llm cell that aggregates history and passes it to the next call. That is consistent with the “messages are atomic” discipline and with the cell-emission-mode table in meclaw-overview.md.
The inference trigger is exclusively messages[]. System updates (paths under system.*) accumulate in cell.db without a provider call.
State in cell.db:
system.*: accumulative-replace per path. Bootstrap context (persona, tool schemas, facts). Updates arrive per message from arbitrary cells, and the sender does not know the structure. Since GH #118 these message writes are gated, by size and slot limits always, and by an optional slot allowlist; see “System write gate” below. Leaves sit incell.dbas{"text": …}: since GH #86 a{text_id}leaf no longer reaches the cell at all, because the substrate resolves it at the delivery boundary. A row persisted before #86 can never cross that boundary again, so if a leaf read back fromcell.dbstill carriestext_id, that is a loud error of the call since GH #95 (error_code: "provider_error",meta.error.source: "translate", every affectedslot_pathnamed) instead of a silent drop out of the system prompt. No provider call, no restart. The way out is to re-send the slot with inline text, whose upsert overwrites the row. Revoking a path, meaning getting rid of it instead of overwriting it, has been possible since GH #264 through the$replacemarker in the body:"$replace": truein a node of the incomingsystemsubtree means “below this node, exactly what this message brings holds”. The node itself is the root, and everything at and below it goes in the same transaction, before this message’s leaves land. Without the marker a write replaces nothing; rules and rationale inmeclaw-overview.md§ Replace semantics.messages[]: last-received as-is, with no appended turns. Blob refs are already resolved here; since GH #19 the substrate expandsmessages_id/text_idbeforehandle(), and the cell never sees a pointer.- Not in
cell.db: the appended assistant turn (the output), and the blob cache, which is in memory only.
The seed (seed/system.jsonl, GH #99) is the static layer underneath the accumulated system.* state. It lets a template ship a default identity instead of starting the cell selfless and waiting for the first system.* update message, so the agent is operational from boot on, degraded until it is briefed and never wrong. Same format as the store seed (overview § seed concept): line 1 is the schema header, lines 2 and following are the rows.
{"schema": {"slot_path": "text", "value": "json", "updated_at": "int"}}
{"slot_path": "identity", "value": {"text": "You are a research assistant."}, "updated_at": 0}
{"slot_path": "instructions.tone", "value": {"text": "Answer briefly."}, "updated_at": 0}
- A row is exactly one leaf:
slot_pathis the dotted slot path,valuethe UBF leaf. The semantics are those of an ordinarysystem.*update (upsert per path).updated_atis carried by the row itself, and0is the customary birth value, since the first realsystem.*update overwrites it anyway. Only thellmfactory’s loader stamps the seed time when the column is absent; the generic JSONL seeder of the mutation stage writes rows verbatim, where a missing column is aNOT NULLfailure. A portable seed therefore carriesupdated_atin the schema header and on every row (GH #386). - Plain-text leaves only. A
{"text_id": …}leaf in a seed is a loud configuration error at spawn time, because the substrate resolves that pointer class at the delivery boundary (GH #86), which a leaf written directly intocell.dbnever passes. Rejected as well: a nested subtree asvalue(then the nesting belongs in theslot_path), an emptyslot_path, a missing header. - The seed applies only on
OpenStatus::Createdof thecell.db. A re-open is never re-seeded, since otherwise the template default would overwrite the grown identity on every restart (overview § seed concept). - The seed is parsed before the
cell.dbis created, so a rejected seed leaves behind no emptycell.dbthat would make the repaired seed look like a “resume” and skip it forever. The same parse path hangs offmeclaw --validate(validate-equals-spawn). - A missing file is not an error, which is the normal case for every cell without a seed.
params:
{
"provider": "openai",
"model": "gpt-4o",
"api_key": "${OPENAI_KEY}",
"base_url": null,
"temperature": 0.7,
"max_tokens": 4096,
"external_timeout_ms": 110000,
"attachment_timeout_ms": 5000,
"credential_wait_ms": 10000,
"credential_wait_max": 16,
"system_order": ["identity", "facts", "instructions", "tools"],
"provider_extra": { },
"system_max_slots": 256,
"system_max_leaf_bytes": 65536,
"system_writable": [ ],
"http_referer": "${OPENROUTER_HTTP_REFERER}",
"x_title": "${OPENROUTER_X_TITLE}",
"reasoning_effort": null,
"reasoning": null,
"auth": "api_key",
"auth_ref": null,
"wire_dialect": null,
"oauth_token_endpoint": null,
"oauth_client_id": null,
"oauth_originator": null,
"oauth_client_version": null
}
external_timeout_ms(concept A, see overview § Timeouts): the A-timeout around the provider HTTP call (tokio::time::timeout), default110000(110 s). On Elapsed: a regular error message withfinish_reason: "error"anderror_code: "timeout".attachment_timeout_ms(concept A, GH #87): the A-timeout around oneattachments[]blob read from the store, default5000(5 s). Much smaller thanexternal_timeout_ms, because a blob read is a local filesystem read and not a provider round trip. On Elapsed: a regular error message withfinish_reason: "error"anderror_code: "timeout", whose detail names the attachment id. Without effect for a cell withoutconsumes.body.attachments, which never reads a blob.credential_wait_ms(concept A, GH #457): the A-timeout around the sealed credential round (cell to broker to vault to cell), default10000(10 s). Taken fromattachment_timeout_ms’s order of magnitude instead of fromexternal_timeout_ms, because it is a round trip inside the colony. On Elapsed every parked message gets itscredential_pendingreceipt. Without effect withoutcredential_grant_id.credential_wait_max(GH #457): how many messages are parked while a credential round is open, default16. Whatever does not fit is receipted withcredential_pendingimmediately, so the buffer never grows past it. Without effect withoutcredential_grant_id.provider: names the wire protocol the cell speaks, and not the vendor."openai"(the OpenAI-compatible HTTP API) is the first and currently only implemented protocol. The value is a string and not an enum, and any other value is hard-rejected at parse time (amodel_not_found/invalid_input-equivalent configuration error at spawn). The vendor choice isbase_url(OpenAI itself, OpenRouter, vLLM, LiteLLM, Ollama, anything speaking the same wire). Further protocols (Anthropic-native, say) will be added when they are concretely needed, tracked as a deferred item with a trigger (maintainer ruling on GH #387).api_key: the static credential of theauth: "api_key"lane, sent asAuthorization: Bearer. An emptyapi_keyis noapi_key(GH #271). The key has to be declared, and missing altogether is a configuration error at spawn, but its value may be empty, and then the cell sends noAuthorizationheader at all on both dialects (chat_completionsandresponses), instead of a header with nothing after it. That is how a keyless OpenAI-compatible endpoint (a local server, a proxy) is addressed. Against an endpoint that would have answered anonymously an empty bearer can be a flat rejection, and that reads as “the provider is down” instead of as “never configured”. The general rule and its converse are inconfig.md§ “The empty value”. The broker token of theoauth_subscriptionlane is not affected, since it does not come fromparamsand is presented verbatim.credential_grant_id(GH #421): no default, optional. The grant this cell presents to obtain its bearer credential sealed from the access hive, instead of carrying it statically inapi_key. Which credential arrives is stated in the grant itself (cred_ref) and not here, so a cell cannot request a secret that was never granted to it (R-AC-2 applied to the vault). For the flow and the precedence overapi_keysee “Sealed credential delivery” below.auth:"api_key"(default) or"oauth_subscription". Selects the credential source, not the provider. Exactly one credential per cell:api_keyis required for"api_key"and forbidden for"oauth_subscription", andauth_refthe other way round. Any violation is a configuration error at spawn whose message never names a param value.auth_ref: path to an OAuth token store in the Codexauth.jsonformat. Required forauth: "oauth_subscription", forbidden otherwise. There is deliberately no default: an implicit~/.codex/auth.jsonwould let a cell rotate therefresh_tokenof a live interactive session, so sharing a store is a config decision and not a code decision.wire_dialect:"chat_completions"or"responses";nullderives it (api_keygives chat-completions,oauth_subscriptiongives responses). A separate axis, orthogonal toprovider: the Responses API is the same vendor with a different wire shape and not a different provider, so theproviderconstraint above is untouched.auth: "oauth_subscription"with"chat_completions"is a configuration error, because the subscription backend speaks Responses only.oauth_token_endpoint/oauth_client_id/oauth_originator: overrides for the OAuth refresh defaults and theoriginatorrequest header.nullmeans the provider default. They exist so an endpoint drift is fixable without a release, and so tests can point at a fake.oauth_client_version: value of theversionrequest header on the subscription lane.nullmeans the provider default (0.147.0). Same rationale as above, only sharper: the backend gates model availability on this header, and an unexpected value is answered with400and “The ‘’ model requires a newer version of Codex.” Without this param, a backend-side bump of the floor would kill the lane until the next release. On the metered lane the header stays this crate’s own version. base_urloverrides the provider default, which is useful for local or proxied endpoints like LiteLLM, Ollama and vllm, all over the OpenAI-compatible wire.system_order: optional order of thesystem.*sub-slots when concatenating into the provider system string. Sub-slots not listed come afterwards in alphabetical order.system_max_slots/system_max_leaf_bytes/system_writable(GH #118): the write gate in front of the persistentsystemtree. See “System write gate” below.temperature/max_tokens: on theoauth_subscriptionlane neither is transmitted, because the backend rejects them with “Unsupported parameter” (measured live). On the official Responses API and on chat-completions they work unchanged, which is why the cut is onauthand not onwire_dialect. A caller who does need one on the subscription lane sets it viaprovider_extra, an overlay that runs after the body inserts and wins.provider_extra: free JSON block for provider-specific knobs (OpenAIseed, for instance). It is an overlay over common params on conflicts. Provider-foreign knobs (Anthropiccache_control, say) are active only with the respective provider translate.http_referer/x_title: optional provider attribution (OpenRouterHTTP-Referer/X-Title). They are regular params (audit ruling A4, params-uniform): set inconfig.json, substituted via${VAR}from.envlike any other param, with no code path reading.envdirectly and no special header mechanics. Unset (nullor omitted) means the header is not sent. The wire target (an HTTP request header instead of the request body) is decided by the translate boundary (see “Provider translate” below).reasoning_effort/reasoning(GH #124,wire_dialect: "chat_completions"only): the deliberation budget for a thinking-class model.reasoning_effortis the shorthand ("low","medium","high", or whatever else the provider accepts, since the value is passed through and not validated) and becomes"reasoning": {"effort": …}in the request body.reasoningis the object block taken over verbatim ({"effort": "low", "exclude": true}or amax_tokensbudget, say) for everything the shorthand cannot express. If both are set,reasoningwins, being the strictly more expressive form; they are never merged. They are regular params likehttp_referer/x_title(audit ruling A4, params-uniform) and changeable at runtime via theparamsslot, because a deliberation budget is a knob and not an identity. Unset (nullor omitted) means the field is not sent and the request is byte-identical to one without these params. On the Responses lane the topic is already covered by the dialect (reasoningitems), so these two params have no effect there.provider_extrais overlaid afterwards and therefore also wins over areasoningblock set this way.
Runtime param updates (config.md § Access L.20): params are cell content, not topology state. They change per message, not per mutation. The form is a top-level params body slot (1:1 with the config.json params block), partial, with last-write-wins per key:
{ "params": { "model": "gpt-4o-mini", "temperature": 0.4 } }
Order within a message: the params slot is merged first and persisted in the cell.db, then a possibly co-sent system/messages inference runs with the updated params, so the same call already uses the new model and the new attribution. A params-only message (a slot without system/messages) persists and stays silent, with no emit, analogous to system-only. config.json thereby diverges from the live state, which is intended; on wake or respawn the cell replays its cell.db overlay over the birth params, and config.json remains the instantiation snapshot. A reset is a cell.db wipe, which brings the bootstrap params back.
Immutable per llm (an update attempt is a loud reject, error_code: "invalid_input", with no partial apply): api_key (credential, secret hygiene, mirror of the A4 Authorization ruling), credential_grant_id (R3/GH #421: the grant is credential identity, and a message able to redirect it would let the cell request and present a different credential), provider (provider identity) and the entire auth dimension, meaning auth, auth_ref, wire_dialect, oauth_token_endpoint, oauth_client_id, oauth_originator and oauth_client_version. The rationale for the extension: auth and auth_ref are credential identity, and wire_dialect and oauth_* decide which endpoint a credential is presented to, so if they were mutable a message could redirect an existing token to a new destination. GH #118 adds the system write gate, system_max_slots, system_max_leaf_bytes and system_writable: a message allowed to raise its own limit or clear its own allowlist would not be gated at all. Unknown param keys are likewise a loud reject, never a silent no-op. A malformed value (wrong type) is a reject, all-or-nothing. The reject detail names only the key or the rule, never a param value.
Sealed credential delivery (GH #421). When credential_grant_id is set and the cell holds no credential in RAM yet, delivery runs in four steps:
- The cell emits a request with
header.route == "credential_request"andheader.grant_id; the body is atool_callturn carrying{"grant_id": …, "operation": "vault.deliver", "payload": {"recipient_key": <64 hex chars>}}. Therecipient_keyis the public half of an ephemeral X25519 pair the cell mints per request; the private half stays in RAM. - The triggering message is parked, not dropped (GH #457): no provider was called and nothing was billed. Every further message that arrives while the round is open joins it, and the vault is asked exactly once per round rather than once per message. The buffer is bounded by
credential_wait_max(default16) and the round bycredential_wait_ms(default10000). It lives in RAM only, like the credential itself, and survives no sleep. - The answer arrives as the body slot
sealed, the sealed box{epk, nonce, ciphertext}. The cell opens it with the ephemeral private half, holds the plaintext in RAM and answers with silence, exactly as for a params-only message. - A broken box, one that does not match, or one that was never requested is a named error (
error_code: "invalid_input"), never a silent failure, and the message never echoes a value.
credential_pending remains the receipt for the three ways this round genuinely fails, and every parked message gets its own, in order: the round runs out of credential_wait_ms (the only way a cell learns of a vault refusal at all, since a broker refusal travels the topology’s error lane and never reaches the asking cell), the delivered box does not open, or the buffer is full. An overflow is receipted immediately instead of at the end of the wait, so the buffer never grows past credential_wait_max. After a timeout the next message opens a new round and asks again.
The delivered credential is never written to cell.db. It is deliberately not part of the params overlay, so it survives no sleep and a woken cell asks again. On precedence: the credential delivered by the vault wins over params.api_key. A non-empty api_key means no delivery is ever asked for, though, so that precedence describes what happens once a box has arrived and never how one arrives: the round above starts only while the cell holds NO credential, and the key in its config is one. Leaving the old key in the config therefore switches nothing over. It keeps the cell on the environment key, silently, and the credential lane carries nothing. The switch is both params in the same act ({"api_key": "", "credential_grant_id": "grant:…"}), and since both are immutable it is a birth act: a cell grown with a key cannot be moved to the sealed lane by a message. params.api_key remains the fallback for every cell that names no grant, so this is no breaking change. Without credential_grant_id the cell behaves byte for byte as it did before R3: it uses api_key and emits nothing. The rule “an empty value is no credential” (GH #271) holds on both lanes.
How a cell gets to its grant (GH #452). credential_grant_id is immutable, so no message may repoint it, no message can mint it either, and the grants row it names has to exist before the cell boots. No manifest can write that row: the diff vocabulary is seven topology operations, and not one of them writes to a store. What does put rows in is a seed/<table>.jsonl, exactly once, into a fresh cell.db. The shipped way is therefore to check the access hive’s store in with its seed and let the instantiation merge around it, since a subtree cell already on disk is left untouched. examples/vault-pilot/ is that way in runnable form, including the derivation rule for the handle that makes it reproducible instead of invented. The credential survives no wake: it lives in RAM only, so a woken cell asks again. The first message after that wake is not lost over it, though, since GH #457 parks it and answers it as soon as the box arrives.
System write gate (GH #118): the system tree is long-term state. It is rebuilt into the prompt on every handle(), it carries the tool menu (system.tools.*), and it survives restarts. Without a gate, any cell with an edge to the llm cell could overwrite identity, instructions and tools durably, in any size and any number. The gate has two independent halves.
- Bounds, always on.
system_max_leaf_bytes(default65536) bounds one leaf,system_max_slots(default256) bounds the number of distinct slots in the tree. Exceeding either is a loud reject, never a truncation and never a silent drop. The slot budget counts the tree and not the batch: overwriting a slot that already exists does not grow the tree, so a cell at its limit can keep refreshing itshandoverand merely cannot open new subtrees. - Allowlist, opt-in.
system_writableis a list of slot-path prefixes (relative to thesystemsubtree:"handover", and not"system.handover") that a message may write. Prefixes match on segment boundaries only ("identity"coversidentityandidentity.soul, neveridentityx). Empty, the default, means no allowlist is configured and every slot path stays writable, which is the pre-#118 behaviour: a system update addressed straight at the cell (the@externaloperator lane) and every topology writer (handoverfrom the summarizer hive,tools.*from MCP discovery,memory/consultfrom the collector hive,identity/instructionsfrom a persona cell) keep working unchanged. Since GH #264 the allowlist covers the replace root too: a$replacemarker revokes every path below its node, including the ones this message never names, so the root has to be writable and not merely the leaves. The consequence at the top: a marker directly undersystemhas the empty root, which is under no prefix, so a cell that declares an allowlist can no longer be cleared wholesale by one message.
All three are immutable per llm (see “Immutable per llm” above): a message able to raise its own limit or clear its own allowlist would not be gated at all.
Be careful when pinning, because a persona cell delivers the identity by message. Several topologies use this pattern: a code cell sends system.identity.soul (and instructions.style) ahead of every turn, as a regular message. Leaving identity out of system_writable without unhooking that cell first turns every turn into an invalid_input reject instead of a write, from the next restart on. Truly freezing the identity is therefore a topology step (persona cell out, seed/system.jsonl in, the way talky does it) and not a config step alone. To close only the unknown surface, declare the prefixes that actually occur, typically ["identity", "instructions", "handover", "tools", "memory", "consult"].
The reject is loud, on two channels: a WARN line on the tracing target meclaw::llm::system_gate (fields reason in not_writable | root_not_writable | leaf_too_large | too_many_slots | malformed_replace_marker, and slot), and a regular error message with finish_reason: "error", error_code: "invalid_input" (the same closed enum as the params-update reject, because it is the same class of event: a message asking for something it is not entitled to), meta.error.source: "parse", and a detail naming the slot path and the violated rule. Never a leaf value in the detail or in the log, under the same secret hygiene as the params reject, since a system leaf is prompt material. The incoming messages[] travel unchanged (gate-1 pass-through, so failover edges stay usable).
All-or-nothing across the whole transaction: a rejected system write also rolls back the messages[] half of the same message and does not reach the provider. There is no half-applied body.
The seed does not pass this gate. seed/system.jsonl is configuration on the same trust tier as config.json; the same hand writes both, side by side in the cell directory. It is the same tier split the params-update reject already draws (config.json may set api_key, a message may not). Checking a declaration against the seed that sits next to it would be circular, and it would lock a pinned cell out of its own identity at boot. The intended shape is the opposite: seed the identity at boot, then pin the message-writable surface to what has to stay live, typically handover and tools. The seed stays validated by parse_system_seed (see “Seed” above).
Tool definitions live in system.tools.<tool_name>.text as JSON strings. The adapter parses them at the provider call and builds the provider-native tool set. Tools are never concatenated into the system-prompt string; they are extracted separately. Tool calls and tool results are their own messages[] turn types (type: "tool_call" / "tool_result" with id as the correlation anchor, a pass-through value from the provider).
Attachments (attachments[]), the vision input (GH #87): an llm cell consumes file attachments exactly when its contract declares consumes.body.attachments (config.md § consumes; the declaration is the switch, and required: false flips it just the same and takes the slot out of the ingress check entirely, GH #323). Only then does it receive a read-only store handle at spawn and resolve the blob_id refs itself, at handle() time. The substrate never inlines them (owner ruling GH #19, see meclaw-overview.md § “attachments[] schema”). A cell without the declaration holds no handle: the slot travels past it untouched, and its provider request is byte-identical to the one without the slot.
- What is consumed:
image/*. Onchat_completionseach attachment becomes animage_urlcontent part of the request’s lastusermessage, whosecontentturns from a string into a content array (the text first, then the images). The URL is a self-containeddata:<mime>;base64,<…>URL, so no dereferenceable link leaves the colony. The authority on the MIME type is the sidecar, which is what the store committed. - Failure modes are cell errors, not dead letters. The message was delivered correctly, and it is the attachment behind it that is unreadable. A non-image MIME type and a missing or uncommitted blob yield a regular error message with
error_code: "invalid_input"; an elapsedattachment_timeout_msyieldserror_code: "timeout". Every detail names the attachment id and the reason, and the inboundmessages[]travel along unchanged (gate-1 pass-through, so failover edges stay usable). An attachment that cannot be read does not reach the provider. - The declared MIME type is checked before the read, so a 40 MB PDF is rejected without ever entering memory.
- Wire dialect: implemented for both dialects (GH #87
chat_completions, GH #94responses). Onwire_dialect: "responses"each attachment becomes aninput_imageitem in the typedinput[]:{"type": "input_image", "image_url": "data:<mime>;base64,<…>"}. On this wireimage_urlis a string and not an object (pinned referenceContentItem::InputImage,openai/codex@266c6920,protocol/src/models.rs:716-734). The items attach to the lastusermessage of theinput[]; without ausermessage they become an appendedusermessage of their own. The error taxonomy and the data-URL form are identical on both dialects.
Output body:
messages[]carries only the new assistant turn, with no pass-through of the incomingmessages[]system.*is not emitted, being private cell statemeta(a cell-specific top-level slot):{ provider, model, response_id, latency_ms, started_at, tokens_cache_read?, tokens_cache_creation?, … }
Output header (hop compartment, expires on the next cell emission):
| Header | Content |
|---|---|
finish_reason | "stop" | "length" | "tool_calls" | "content_filter" | "error", mandatory |
tokens_prompt | input token count |
tokens_completion | output token count |
model | model the provider actually used |
error_code | only on finish_reason == "error": "rate_limit" | "auth" | "timeout" | "model_not_found" | "provider_error" | "invalid_input" (param-update reject, immutable, unknown or malformed key) | "credential_pending" (R3/GH #421, GH #457: the cell declares params.credential_grant_id, holds no credential in RAM, and the round failed, meaning credential_wait_ms elapsed, the box did not open, or the buffer was full. No provider was called and nothing was billed; the triggering message gets this code only in those cases, and is otherwise parked and answered) |
The error_code enum is additively extensible. New failure classes may add a value, and existing values never change their spelling or their meaning (the same promise the dead-letter and mutation codes carry, docs/meclaw-overview.md). A CEL condition must therefore not assume the list is complete: match on the codes you handle and give the rest a default lane, because an unmatched code is a future release and not a bug in your topology. A planned addition is quota_exhausted, and it will arrive this way, as an added value with nothing renamed.
meta.error fine classification. The subscription lane deliberately took no new enum value; the discriminator a failover edge needs lives in meta.error instead:
| Case | error_code | meta.error.kind | extra |
|---|---|---|---|
| subscription quota spent | rate_limit | quota_exhausted | resets_at (unix seconds), plan_type |
| plan does not cover the model | rate_limit | plan_not_included | none |
| ordinary rate limit | rate_limit | rate_limited | none |
| token expired, even after refresh and one retry | auth | auth_expired | none |
| refresh token permanently dead | auth | auth_permanent | re_login_required: true |
| token store missing or unreadable | auth | auth_store_unavailable | none |
| 5xx or overload | provider_error | transient | none |
| upstream error inside a 200 body (GH #75) | that of the stated status | that of the stated status, else coarse (rate_limited / unauthorized / model_not_found / provider_error) | in_body: true, upstream_status (when stated), upstream_message |
Pre-P10 failure paths emit no kind, and their message is unchanged.
An error inside a 200 body (GH #75). An OpenAI-compatible gateway reports an upstream failure as a regular HTTP 200 whose body carries no choices at all, only a top level error object ({"error": {"message": …, "code": 429}}). That is a normal signal and not a malformed body. The cell classifies this shape before it reads choices[0], and through the same status table a real HTTP status goes through: a 429 in the body lands in exactly the lane an HTTP 429 lands in (rate_limit), 401 and 403 in auth, 5xx in provider_error with kind: transient. If the body states no status, the prose decides (rate-limit shaped sentences give rate_limit), and otherwise it is provider_error. meta.error.source is wire and not parse, and meta.error.upstream_message carries the provider’s own sentence. missing choices[0] stays reserved for a body that has neither choices nor error.
Phase instrumentation (GH #124). Per provider call the cell writes one INFO line to the tracing target meclaw::llm::latency (RUST_LOG=meclaw::llm::latency=info), as tracing fields only, with no UBF slot and no substrate change. Fields: dialect, model, outcome (ok or the error_code), persist_ms (body parse plus the cell.db write transaction), translate_ms (system-tree read-back, tools, prompt concatenation, attachments[] resolution, request build), provider_ttfb_ms (until the response head, the time that sits with the provider), wire_total_ms (the full HTTP roundtrip including body/SSE drain), wire_attempts (>1 only for the subscription lane’s 401-refresh retry), handle_ms (the whole handle()) and unaccounted_ms = handle_ms − (persist_ms + translate_ms + wire_total_ms). The phases are complete, so a large unaccounted_ms is itself a finding (“the time is in none of the measured phases”) and not a gap in the record. An Option field without a value is omitted, never rendered as 0: a line without wire_total_ms is a call that never reached the provider, and a line with wire_total_ms but without provider_ttfb_ms is a call the provider never answered. On DEBUG there is additionally one detail line per built request (request_bytes, input_turns, tools, image_parts, system_prompt_chars), carrying sizes and counts only, never conversation content and never a credential. Paths that end before the request build (a body-parse reject, a params reject, system-only silence) emit no line, because they called no provider.
Aggregation over loops (total cost, cumulative tokens) is not a cell feature. A separate aggregator hive in the topology groups over correlation_id and augments pass-through headers (cost_total_usd, tokens_total). Rationale in meclaw-overview.md section “Metadata aggregation is topology”.
Error model: provider errors (rate limit, auth, timeout and so on) are regular output messages with finish_reason: "error" plus error_code, messages[] unchanged (no turn appended), and meta.error with detail info. Topology can do failover via an edge condition. Only internal errors (a panic, bad params) trigger a cell crash and restart.
Streaming is not supported as output (single-message output), post-roadmap. The transport is a separate matter: the Responses dialect streams on the wire (stream: true is mandatory on the subscription backend, which has no non-streaming path). The cell consumes the SSE body fully and non-incrementally and folds it into one atomic message, so this cell’s atomicity guarantee is unchanged.
Multi-provider: the cell implements exclusively the OpenAI translate, one provider per instance. Anthropic is deferred. The cell logic (UBF consumption, system.* accumulation in cell.db, tool-definition extraction, atomic-emit, error model) is provider-agnostic, and provider-specific is solely the translate (see “Provider translate” below). Failover or an A/B test over several providers runs via topology (two llm cells plus a dispatcher hive under one hive scope), never cell-internally. Additionally conceivable post-roadmap: a cell-internal provider list for a resilient provider connection, where the cell guarantees “communication to the provider works” via retries and failover.
Provider translate (the translation boundary): the llm cell is provider-agnostic. It consumes exclusively universal body format, accumulates system.* as UBF in its cell.db (UBF is thereby also its internal and persistent format) and emits exactly one assistant turn as UBF. All provider knowledge lives in a translation function (here “translate”, synonymous with the “LLM provider adapter” named in meclaw-overview.md), which knows two directions: UBF to provider-native request (system concatenation, messages[] mapping, system.tools.* into the provider-native tool set) and provider-native response to UBF (the assistant turn including any type: "tool_call" turns, headers like finish_reason and tokens, the meta slot). Consequences every implementer must observe:
-
No loop. Exactly one provider call per inference message, then emit. Iteration is topology (see
meclaw-overview.md“Iteration is topology”). -
No composing or decomposing of tool calls. The cell does not assemble tool calls and does not resolve any.
tool_callandtool_resultare pure UBFmessages[]turn types withidas the pass-through correlation anchor (a value from the provider). Tool schemas are translated by the translate fromsystem.tools.*into the provider-native tool set, which is format translation and not a tool loop. -
Wire merge of consecutive
tool_callturns (request build, ruling 2026-06-11). During UBF-to-request mapping the translate merges consecutive assistanttool_callturns into one provider-native assistant message withtool_calls[]. The OpenAI wire contract requires that an assistant message withtool_callsis immediately followed bytoolmessages for eachtool_call_id(Run-4b wire finding: one-call messages before collected results gave a 400). This is pure wire-format translation within the translate boundary and not composing at the UBF level: UBF stays unchanged (one turn = one call = oneid), andids stay pass-through. The response return path stays unchanged, with each providertool_calls[i]becoming its own UBF turn. -
Provider-native JSON never leaves the translate boundary. The cell core sees exclusively UBF, and provider-specific structures exist only within the translate.
-
Param to wire-target mapping (audit ruling). The translate boundary decides the wire target per param, request-body JSON or HTTP request header, so provider knowledge resides exclusively in the translate. The explicit table:
param wire target model,temperature,max_tokens,provider_extra(overlay)request-body JSON reasoning_effortrequest-body JSON reasoningas{"effort": …}(chat-completions only)reasoningrequest-body JSON reasoning, verbatim (chat-completions only; wins overreasoning_effort)http_refererHTTP header HTTP-Refererx_titleHTTP header X-TitleThe header table is a closed allow-list.
Authorizationis not a params-controllable header: it is theapi_keybearer and is set solely by the wire layer, and a params attempt to override it is ignored (secret hygiene). Only set (Some) attribution params produce a header; unset means no header.
From this follows the deferral cleanliness: a further provider (Anthropic, say) is solely a second translate plus a widening of the provider check, while the cell logic, the cell.db semantics and the error model stay unchanged.
Response sanitation (GH #569). Provider-internal annotation markers never reach UBF. Some models decorate the answer text with inline citation markers built from Private-Use-Area codepoints. The observed shape is U+E200 cite U+E202 turn0search0 U+E201, a reference to the provider’s own tool-round numbering. The PUA characters render invisible or as boxes, and the enclosed text as literal junk (citeturn0search0); no provider-internal token may reach a person. The response translation of both wire dialects strips them before the text enters the assistant turn: a span from U+E200 up to and including the next U+E201 falls with its content, a U+E200 without a closing codepoint (a truncated response) takes the rest of the text with it (behind it there is marker content only, by construction), and any other PUA codepoint falls on its own. Everything outside stays byte-identical, with no trimming and no whitespace normalisation. In the Responses dialect the stripping happens after the output_text parts are joined, so a marker split across two parts falls too.
Second wire dialect: Responses. Beside chat-completions the translate knows the Responses dialect: the same translation boundary, the same UBF semantics, a different wire shape. messages[] becomes typed input[] items (input_text/output_text, function_call/function_call_output), the system prompt becomes the top-level instructions, max_tokens becomes max_output_tokens, and tool schemas are flat instead of nested. store: false is set, because the subscription backend does not persist, and include: ["reasoning.encrypted_content"] only on the subscription lane. The answer is read from response.output_item.done and not from the deltas; reasoning items never reach UBF.
The wire is pinned against the reference implementation github.com/openai/codex @ 266c6920d9b82fe4d68959529565256b12a9be99 (endpoint, header set, body shape, SSE events, refresh flow, error taxonomy), and the test fixtures are the drift detectors. Endpoint: https://chatgpt.com/backend-api/codex/responses (subscription, without /v1) or https://api.openai.com/v1/responses (API key). The two are not interchangeable: a subscription token against the metered endpoint fails on missing scopes.
Token lifecycle (auth: "oauth_subscription" only). The access token is not a param: it is cell-external, rotating state in the store behind auth_ref. Refresh is purely reactive: call, 401, refresh, exactly one retry, typed error. No timer, no polling, no backoff loop; failover on quota exhaustion is topology and not cell logic (see the error model).
One refresher per process. The refresh_token rotates on every refresh, and two cells refreshing the same store concurrently produce refresh_token_reused and kill the login permanently. All cells in a process therefore share one token broker: an actor that performs the refresh call itself, which guarantees single-flight by construction, with no lock and no wait loop. A cell that wants to refresh after a 401 names the token generation it used; if someone else refreshed meanwhile, it receives their fresh token instead of a second rotation. The limit: this serializes within one process, and a CLI running in parallel on the same store can still collide (see ROADMAP.md).
Secret hygiene, the api_key discipline extended to the token path. A token is never in config.json, never in the message_log, never in an emitted message or its meta, never in an error text and never in a log. Debug output of the token types redacts its values, and third-party error texts are stripped of token-shaped strings before being passed on. The store is written atomically (temp file plus rename) and carries Unix mode 0600.
Store co-ownership. The store also belongs to the vendor CLI, so MeClaw is a second writer there and not the owner. Rotation therefore patches instead of overwriting: only tokens.access_token, tokens.refresh_token, tokens.account_id and last_refresh are touched, and all unknown fields (auth_mode, id_token, OPENAI_API_KEY, …) survive unchanged. A naive rewrite would destroy an interactive login on the first rotation.
bash, shell execution
Runs shell commands, one-shot only (cell.timeout > 0, and the cell terminates after each message). A persistent mode (cell.timeout: -1, a long-lived interactive shell session) is by design not introduced (architecture ruling 2026-06-08): stateful, fragile, hard to sandbox. Continuity of cwd and env across several commands, where it is needed, runs by persisting them in the bash cell.db and passing them per one-shot call, never via a living shell. For program logic, body manipulation or multi-send see code.
State model: bash is stateless in the classical sense (a stateless dispatcher with short-lived worker tasks) and has no cell.db, consistent with the discipline “tool cells without cell.db”. Shell state (cwd, env vars, history, open processes) is not held across calls, and each call starts a fresh shell.
Emission mode: atomic-emitting. One tool_result turn per executed command.
Body format of the response: messages[] with one turn { origin: "tool", type: "tool_result", text: "<stdout-plus-possibly-stderr>", id: "<tool_call_id if present>" }.
The stderr convention: stderr gets no header and no body slot of its own. It is appended in text after the stdout portion, demarcated by sentinel markers, and inserted only when stderr is non-empty:
<stdout-content>
##meclaw-stderr-start##
<stderr-content>
##meclaw-stderr-end##
This way an LLM consumer reads the full tool output naturally (stdout first, stderr explicitly marked), and edges can route quickly via header.had_stderr before the text parse. Three alternatives were rejected: stderr as its own header string (which would break the “headers are small” discipline with large compiler outputs and stack traces), stderr as its own top-level body slot (which breaks the natural LLM-consumer model “reading tool output means reading text” and increases slot inflation), and stderr always as a JSON struct in text ({stdout, stderr, exit_code}, not directly LLM-readable without a parse step).
Output header (hop compartment, expires on the next cell emission): operation (= "bash"), exit_code, duration_ms, had_stderr (mandatory, always set), bytes (the full length of the combined output before a cut), optional truncated (true when max_bytes cut, GH #83, see below).
params: typically the command to execute or the script-path convention. max_bytes (byte cap on the returned output, default 262144 = 256 KiB, GH #83). Optional sandbox (S4/GH #35, completed in GH #85; schema in config.md § params), the process sandbox block for the spawned shell.
Size cap (max_bytes, GH #83). A command with runaway stdout has the same multiplying effect inside a tool loop as an uncapped web_fetch body: the tool result becomes a thread row and re-enters the prompt on every subsequent round. max_bytes (default 256 KiB, the same generous but finite value web_fetch uses) cuts the combined text (stdout plus the stderr sentinel block, if any) on a UTF-8 boundary. A trim is visible, never silent: text ends in … [truncated, <N> bytes total], header.truncated: true, and header.bytes reports the full size before the cut. A cut may clip the stderr block, and the marker and header.had_stderr stay the reliable signals. Inside an agent loop the value belongs much lower.
Length cap on command (GH #351). Linux caps a single argv string at MAX_ARG_STRLEN = 32 * PAGE_SIZE = 131,072 bytes, independent of ARG_MAX and not raisable, and bash hands the command to /bin/sh -c as exactly such a string. A command at or above that line is therefore refused before the spawn: error_code: "invalid_input", the text names the actual byte size and the limit, and no shell is started. The same case used to die inside spawn() with Argument list too long (os error 7) and came back as io_error, as if the child had failed. The route code takes for an oversized script_inline (a per-spawn temp file, GH #349) is deliberately not taken here: sh <file> is not sh -c <command>, so $0 would become the script path, $1… would shift by one, and a command reading positional parameters would change meaning above the cap. Whoever needs 128 KiB of program or more uses code.
Conventions:
exit != 0is a NORMAL tool_result:exit_codeis always in the header,0included. The LLM or caller reads the code and decides. Consistent with Claude Code’s Bash tool.- Only a spawn failure, a timeout and invalid input are errors:
error_code: "io_error"(spawn),"timeout"(external_timeout elapsed) or"invalid_input"(a missing or invalidcommandfield). exit_code = -1on signal-killed or abnormal termination, a platform-unspecific convention. On a timeout there is additionallyerror_code: "timeout".- stderr sentinel format, inserted only when stderr is non-empty:
<stdout> ##meclaw-stderr-start## <stderr> ##meclaw-stderr-end## - The
had_stderr: boolheader is always set, true or false. - Security boundary via
params.sandbox(GH #35, completed in GH #85). Without asandboxblock, bash still has full FS access via the shell and full network, and the trust model from beforeparams.sandboxapplies unchanged to that case. A bash cell instantiated from a template gets the block automatically, though (the default-deny cut,config.md§params). Withsandbox: {"trust": "restricted", ...}the shell starts under a Landlock filesystem allowlist, inside a fresh network namespace whennetwork: "deny", under the declaredlimits(cgroup v2) and behind the declaredsyscallsfilter (seccomp-bpf). Schema, the operating requirement for the caps and the fail-closed rule:config.md§params. Particularly relevant for a shell: undersyscalls.foreign_signals: "deny"the script may only signal itself, so akill $!on its own background job fails withEPERM. - Shell:
/bin/sh -c <command>.cwdandshellas params are deferred, and an operator sets them inline viacd /x && cmd. - No persistent bash (
cell.timeout: -1): dropped (architecture ruling 2026-06-08).bashis one-shot only, and this is not a deferred option. - Minimal input:
{"command": "..."}. - Defaults:
max_concurrency: 4,external_timeout_ms: 60000,max_bytes: 262144.
code, a programmable body constructor
code runs a user-supplied program in a declared language (Python first; Node and others later). Where bash emits output, code is a body constructor: the script gets the incoming message as JSON and builds the outgoing content JSON entirely itself, meaning headers, messages[], its own top-level slots and routing-relevant headers for edges. That is what code is for: dissecting LLM outputs, extracting tool calls, transform logic, multi-send dispatchers.
Emission mode: script-determined, so atomic-emitting or stream-propagating, depending on whether the script passes through the incoming messages[] or builds it anew. code is the only cell type without a fixed emission mode.
Script interface:
- stdin: a JSON document of exactly three objects (since 0.9.0),
envelope,bodyandparams. All three are always set and always objects, even when empty. - stdout: the complete content JSON in exactly the form every other cell also produces, a
headersection (optional) plus top-level slots. The wire format is unchanged: the script still writes aheadersection. Colony interprets this ashop(the isolated cell output, expiring on the next cell emission), and the rest becomesmessage.body. The script does not writecontext, which is solely edge authority. The stdin structure changes nothing about stdout, and the emission is the same as before 0.9.0.
The three objects on stdin. build_stdin_json (crates/meclaw-cells/src/code/wire.rs) builds:
{ "envelope": { "header": {"context": {}, "hop": {}},
"target": "/x", "trace_id": "...", "ttl": 64,
"reply_to": "/sink" },
"body": { "messages": [], "…": "further body slots" },
"params": { "window_size": 7 } }
envelopecarries everything the substrate puts around the payload:header(both compartmentscontextandhop),target,trace_id,ttl, plusreply_to,parent_message_idandcorrelation_idwhen the message carries them.bodycarries the body slots of the incoming message (messages,system, cell-owned slots) verbatim.paramsis the read-only copy of the cell’s own configuration,{}when nothing is left.
The top level is closed by construction. A script reads its payload from body, and does not derive it by subtracting a hard-coded envelope key list. That was precisely the failure class the structure ends: with a subtraction script every new top-level field fell into the body automatically and travelled on with the outgoing message. Future wire data therefore travels inside one of the three objects instead of beside them, and a body slot can no longer shadow envelope or params, because it no longer shares their namespace.
The params are a read-only copy. The cell reads nothing back from it, and whatever the script changes there dies with the process. Two classes do not travel, recursively at every nesting level: (a) credentials, meaning the keys api_key, auth, auth_ref, token, secret, password exactly, plus anything ending in _key, _token, _secret or _password (auth is an exact key and not a prefix, so author keeps travelling, and max_tokens is a budget and keeps travelling too); (b) the script’s own source (script_inline / script_path), which would double the wire payload of every single message without giving the script anything it is not already. A code cell is thereby configurable per instance without forking its script, and ${VAR} substitution (which applies at bootstrap and at mutation instantiation) stays the route for colony-global values. The context route is still no substitute for either: the /colony reply comes back with an empty context, so a two-phase cell would lose its configuration exactly when it needs it.
Multi-send: when multi_send_capable: true (the source is contract.multi_send_capable from the cell’s config.json, and the earlier params.multi_send_capable bridge is removed), the script may write a JSON array of content JSONs to stdout instead of a single content JSON. The cell discriminates by the JSON root type:
- a JSON object gives one outgoing message (the standard case).
- a JSON array gives N outgoing messages, one per element, in array order.
If multi_send_capable: false and the script writes an array, that is a contract violation and an error message with error_code: "multi_send_not_declared". If multi_send_capable: true and the script writes an object, that is allowed and treated as an array of length 1.
Each emitted message runs independently through the cell’s outgoing edges. Colony evaluates all edge conditions freshly per emitted message, so one message can land at edge A and the next at edge B.
Wire example:
[
{ "header": { "msg_type": "tool_call" },
"messages": [{ "origin": "assistant", "type": "tool_call", "id": "call_a", "text": "..." }] },
{ "header": { "msg_type": "tool_call" },
"messages": [{ "origin": "assistant", "type": "tool_call", "id": "call_b", "text": "..." }] },
{ "header": { "msg_type": "user_visible" },
"messages": [{ "origin": "assistant", "type": "text", "text": "Three tools are being called in parallel." }] }
]
Two alternatives were rejected: multi-send via NDJSON (line-delimited JSON, which brings no advantage because the cell waits for script end and there is no streaming need), and multi-send with an explicit wrapper ({ "messages": [...] } around the array, unnecessary because JSON-type discrimination suffices).
Cell standard headers, set by the cell itself after script end, overriding the script output for these keys:
exit_code(number)duration_ms(number)had_stderr(bool)
The script cannot hijack these keys. Process metadata belongs to the cell.
stderr on a successful script run (exit 0) is not injected into the script output, so the script’s body construction stays clean. header.had_stderr is set, and the stderr content lands in log.jsonl at warn level. On a script error (exit != 0, see the failure model) the cell instead emits an error message with stderr in the bash convention.
Failure model, the complete error_code list:
- stdin not valid JSON (the incoming message unparsable): an error with
error_code: "invalid_input", and no DB write. - script spawn fails (the runner is not startable): an error with
error_code: "io_error". external_timeout_mselapsed (the script ran too long): an error witherror_code: "script_timeout".- script exit != 0: the cell discards the script output and emits an error message with
header.finish_reason: "error",header.error_code: "script_failed",header.exit_codeandheader.had_stderr. The body is atool_resultturn with stderr in thebashsentinel-marker form (stdout, then the demarcated stderr block). - script stdout not valid JSON: an error with
error_code: "invalid_json". - script stdout valid JSON but not the wire shape: an error with
error_code: "invalid_json". Two cases, both of them foreign input rather than a bug: an emitted message that is not an object ([1],["x"], since the top level may be an array while every ELEMENT must be an object), and aheaderkey whose value is not an object ({"header": 5}). The reject is total: in a multi-send, a bad shape in any message yields exactly oneinvalid_jsonreply and zero regular emissions. - script writes a JSON array without
multi_send_capable: an error witherror_code: "multi_send_not_declared". - script stdout valid, but
contract.emitsviolated: an error witherror_code: "contract_violation". Thiscodevalidation runs always-on, unconditionally, independent of build profile and ofcolony.jsonstrict_validation, becausecodeis the only user-script-driven trust boundary; seemeclaw-overview.md§ “Schema validation: timing and scope” andconfig.md§ Schema format and validation.
params: typically runner (canonically "python3"; CodeParams::parse rejects other values with 'params.runner: only "python3" is supported in Phase 9'. Background: on the target platforms Ubuntu 24 and Python 3.12 the real binary is /usr/bin/python3, and python does not exist there), a script path or inline code, and external_timeout_ms (concept A, see overview § Timeouts; default 60000). multi_send_capable is no longer in params: it comes from contract.multi_send_capable (see Multi-send above). Optional sandbox (S4/GH #35, completed in GH #85; schema in config.md § params), the process sandbox block for the spawned runner. A script under trust: "restricted" reads only the declared paths, so a script delivered as script_path instead of script_inline must itself live under one of them. A code cell instantiated from a template without a block of its own gets the default-deny profile (config.md § params), and its runtime set is enough for a script_inline, while a script_path needs a declaration.
How the script reaches the runner (GH #349): a script_path is started as <runner> <path>, and a script_inline normally as <runner> -c <code>. Linux caps a single argv string at MAX_ARG_STRLEN = 32 * PAGE_SIZE = 131,072 bytes, independent of ARG_MAX and not raisable. A script_inline above that line used to be written into argv anyway, and spawn() answered Argument list too long (os error 7), so the cell never started. Since GH #349 a script_inline above the cap is written to a per-spawn temporary file and the runner is pointed at that path, the very <runner> <path> form script_path already uses. The file is created with mode 0600, exists for the length of the spawn and is removed afterwards. stdin stays free, because that is where the document travels. Below the cap nothing changes: the -c form stays, and with it sys.path[0], __file__ and the shape of a traceback. The sandbox promise above still holds: a script_inline needs no path declaration of its own, and the substrate grants the materialised file exactly one read right and nothing else.
Runner modes (params.runner_mode, default "cold"): three declared ways to run the same script. They are opt-in, and a cell without the key behaves exactly as it did before they existed.
cold: a fresh process per message. The default, and the reference the other two are measured against.warm: a pool ofmax_concurrencyresident runner children. A child starts once, compiles the script once (compile(), without running it), and from then on runs the body per message in a freshglobalsnamespace. What stays warm is the interpreter andsys.modules, and nothing the script writes into its namespace survives the message. A warm runner changes latency, never semantics: there is nowarm_cache, no switch and no exception through which a script could carry something over after all.resident: exactly one child, strictly serial FIFO.max_concurrencyis forced to1in this mode, and a declared value that says otherwise is a loud param reject at spawn (params.max_concurrency must be 1 when runner_mode is "resident"), just asmax_concurrency: 0already is. Theglobalsnamespace does survive the message here, which is whatresidentis for. The binding rule that comes with it: RAM is a cache of the cell’s durable store, never its truth. Whatever a resident script did not write through the topology (store) or into a file of its own is gone with the next child, and that must not be observable, so the same message stream with a child killed halfway through produces the same outputs.
The isolation promise, made precise. It used to read “a fresh process per message”; it now reads a fresh namespace per message, for cold and warm alike, because what is observable is the namespace and not the pid. resident is the declared exception, and the cache rule above is its price.
external_timeout_ms (the A-timeout) applies per execution in all three modes. On elapse the child is killed (and, under warm or resident, replaced) and the cell answers script_timeout, from the same error_code list as above, unchanged. A warm child that dies costs exactly one message its answer, in the shape a cold child would have produced too (script_failed or invalid_json).
Do not confuse this with the hot/cold cell model (meclaw-overview.md § Hot/Cold cell model). There “cold” means asleep, and the axis applies to stateful cells only. code is stateless and has no wake state at all, and cold/warm/resident say nothing but how long a runner process lives.
Sandbox and argv. A resident child gets the same sandbox profile a cold one gets (params.sandbox, config.md § params), and the mode changes the script’s rights nowhere. Under warm and resident the script itself does not travel in argv but on the first line the child reads, so the MAX_ARG_STRLEN limit of GH #349 and its temp file do not arise there. A child is replaced when the script or the params change, since the cell is re-instantiated then, and a crash respawn of the dispatcher keeps the warm pool.
A cell.db for code is deferred. DB access from script logic runs via topology (code, multi-send, store) and not in-process. Whoever needs a collector or state pattern in code lifts that into a separate design pass.
web_fetch, an outbound HTTP client
A pure HTTP tool. Stateless, with no cell.db. Only GET is implemented (see the conventions below); POST, PUT, PATCH and DELETE including method, headers and body are a roadmap defer.
Emission mode: atomic-emitting. One tool_result turn per HTTP call.
Body format of the response: messages[] with one turn { origin: "tool", type: "tool_result", text: "<response body>", id: "<tool_call_id>" }. On a large body the entire output message is offloaded as Body::Blob, a whole-body offload at the delivery boundary (blob_inline_max_bytes threshold, resolve_blob_for_delivery), and never an in-message text_id pointer. This cell produces no in-message pointers, and that the substrate can now resolve them (GH #19) does not change it: whole-body offload stays the form in which a large response leaves the wire.
Output header: operation (= "web_fetch"), http_status, content_type, duration_ms, bytes, optional truncated, optional redirects and final_url (only when at least one redirect was followed, GH #117).
params: max_bytes (byte cap on the returned body, default 262144 = 256 KiB, GH #83), max_concurrency, external_timeout_ms, allow_private_networks (default false, GH #117), max_redirects (default 5, GH #117); later base_url, default headers and optional auth configuration.
SSRF hardening (allow_private_networks and max_redirects, GH #117). web_fetch runs inside the daemon process, and no child is spawned, so sandbox.network: "deny" can never cover this cell. The cell therefore enforces its own egress policy, and that policy is default-deny.
- Private-network deny. Every target address is screened against a range matrix, after DNS resolution. IPv4:
0.0.0.0/8,10.0.0.0/8,100.64.0.0/10,127.0.0.0/8,169.254.0.0/16,172.16.0.0/12,192.0.0.0/24,192.168.0.0/16,198.18.0.0/15,224.0.0.0/4,240.0.0.0/4. IPv6:::,::1,100::/64,fc00::/7,fe80::/10,fec0::/10,ff00::/8. Every v6 form that embeds a v4 address (::ffff:a.b.c.d, the deprecated::a.b.c.d, NAT6464:ff9b::/96, 6to42002::/16) is judged by the address it embeds. Obfuscated spellings (http://2130706433/,http://0177.0.0.1/) are normalised by the URL parser before the deny ever sees them. - No DNS-rebinding window. The pre-flight check produces the readable refusal; what makes it true is a dedicated reqwest DNS resolver that returns screened addresses and never hands a blocked one to the connector. reqwest therefore connects only to addresses that passed, so the address that was checked is the address that is dialled. A name whose address set contains a private address is refused whole (deny-if-any).
- Redirect policy. Redirects are followed by the cell and not by reqwest (
redirect::Policy::none()): reqwest’s policy closure is synchronous and cannot resolve a name, so a hop it followed would never see the deny again, which is the classic bypass (a public URL answering302 Location: http://169.254.169.254/). Every hop, the first included, is re-screened before the connect, andmax_redirectscaps the chain (exceeding it givestoo_many_redirects). A3xxwithout aLocationis a document with a 3xx status instead of a redirect, and goes back as a normaltool_result. ALocationnaming a foreign scheme, and a downgrade fromhttpstohttp, are refused (invalid_redirect); the upgrade direction fromhttptohttpsstays allowed. The whole chain lives inside one operation timeout (rule 12 A), because a redirect budget is not a time budget. - Opt-out, two tiers.
allow_private_networks: trueopens the ranges a local topology can legitimately live in (loopback, RFC 1918, ULA, CGNAT, site-local), for mock servers in tests and services on the same host. Link-local (169.254.0.0/16,fe80::/10) stays shut in both tiers: that is where the cloud metadata endpoint169.254.169.254sits, and nobody deliberately runs anything there. A non-boolean value for the opt-out is a param reject, never a silent reinterpretation. - A refusal is a regular error message (rule-12 shape), not a panic and not a dead letter:
error_code: "target_blocked", on its own lane. Neitherio_error(which reads as “network problem, retry”) norinvalid_input(which reads as “the URL is broken”) fits a blocked target, and the only repair is a different target.
Size cap (max_bytes, GH #83). A fetched body is a tool result, and inside a tool loop a tool result is re-sent to the model on every subsequent round, so one large fetch does not cost one prompt, it costs every remaining prompt of the turn. max_bytes is therefore generous but finite: the default passes an ordinary document whole and stops a multi-megabyte payload. A trim is visible, never silent: text ends in … [truncated, <N> bytes total], header.truncated: true (the declared header finally has a producer), and header.bytes reports the full size the server sent, not the size of what survived. Inside an agent loop the value belongs much lower; the worked example uses 32 KiB.
Conventions:
GETonly.method,headersandbodyare deferred.- Minimal input:
{"url": "..."}. - A non-2xx HTTP status is a NORMAL tool_result with an
http_statusheader. The LLM or caller reads the status. Only DNS, connect, timeout, invalid input and the egress policy produce error messages (io_error,timeout,invalid_input,target_blocked,too_many_redirects,invalid_redirect). - The input gate PARSES the URL (GH #110): missing, non-string, empty, syntactically broken (
"not-a-url","http://") and foreign-scheme URLs (anything buthttp/https,file://included) areinvalid_input, and the text quotes the URL. Before, the gate only checked presence and type, and a syntax error surfaced from reqwest and was mapped toio_error; an agent repairing its own call readsio_erroras “network problem, retry” and thus applies the wrong repair forever.io_errorstays what it should be: DNS, connect, transport. The URL itself travels on unchanged, since the parser never re-serializes it. - TLS: rustls (the
rustls-tlsfeature of reqwest); no OpenSSL and no native-tls in the tree. - Header:
operation: "web_fetch",http_status: u16(mandatory),content_type: String,duration_ms,bytes; after a redirect alsoredirects: u64andfinal_url: String(GH #117, so that a body which came from somewhere other than the requested url says so). - Truncation:
max_bytes(GH #83, see above) cuts visibly; below it large bodies stay inline intextand are offloaded as a whole-body blob when needed. reqwest::Clientper cell instance (internally Arc, no Mutex). A build error at spawn is a spawn error. RespawnFn clones the initially built client.- Defaults:
max_concurrency: 32,external_timeout_ms: 30000,max_bytes: 262144,allow_private_networks: false,max_redirects: 5.
web_search, a search-provider client
A pure search tool that talks to an external search provider (Brave, Tavily, SerpAPI). Stateless, with no cell.db.
Emission mode: atomic-emitting. One tool_result turn per search request.
Body format of the response: messages[] with a tool_result turn whose text contains the search results as a JSON list (title, URL, snippet per hit). On large result lists the entire message is offloaded as Body::Blob at the delivery boundary, and never via an in-message text_id pointer.
Output header: operation (= "web_search"), result_count (the full provider count, even when the list was cut), duration_ms, bytes (the full size of the provider response), optional truncated (true when max_results or max_bytes cut, GH #83, see below).
error_codes: io_error (a DNS or connect error), timeout (external_timeout elapsed), invalid_input (a missing or invalid query). A merely non-conformant provider response is not an error (see the conventions: result_count=0, body passed through).
params: typically the provider base_url and API token (via ${VAR} substitution). max_results (list cap, default 10, GH #83) and max_bytes (byte backstop on the text, default 262144 = 256 KiB, GH #83).
List cap (max_results) and byte backstop (max_bytes), GH #83. A result list is a tool result, and inside a tool loop it re-enters the prompt on every subsequent round. A conforming list with more than max_results hits (default 10, a full first page) is trimmed in place: the JSON stays valid and carries the cut visibly where the model reads ("truncated": true, "total_results": <N> in the object, because the hop header does not travel into the thread row). header.result_count keeps the full provider count and header.bytes the full response size. max_bytes is the backstop for what the list cap cannot catch (a non-conforming pass-through body, absurdly large snippets), following the identical web_fetch convention: cut on a UTF-8 boundary, with a … [truncated, <N> bytes total] marker in text. When neither cap bites, the provider body passes through byte-identical, with no re-serialization.
Conventions:
- Generic JSON wrapper: the cell does GET
<params.endpoint>?q=<query>with an optionalparams.api_keyas bearer token. It expects the response{"results":[{"title","url","snippet"}]}. An emptyapi_keyis noapi_key(GH #270): templates write it as${SEARCH_API_KEY:-}, and an unsetSEARCH_API_KEYturns that into"", whereupon the cell sends noAuthorizationheader at all instead of a header with nothing after it. Against an endpoint that would have answered anonymously, an empty bearer is worse than none. - Provider-specific adapters (Brave, Tavily, SerpAPI) are deferred. Application topology normalizes the result in a
codecell beforehand. - Input:
{"query": "..."}. - Graceful on a non-conformant response:
result_count=0when theresultskey is missing or not an array. The body is ALWAYS passed through intext, with no hard error. - Header:
operation: "web_search",result_count: u64,duration_ms,bytes. Thehttp_statusheader is deferred here; parity with web_fetch would be more consistent, but is post-Slice-3. - Truncation:
max_resultsandmax_bytes(GH #83, see above) cut visibly; below them large result lists stay inline intextand are offloaded as a whole-body blob when needed. reqwest::Clientper cell instance, analogous to web_fetch. A build error at spawn is a spawn error. RespawnFn clones the client.- Defaults:
max_concurrency: 8,external_timeout_ms: 15000,max_results: 10,max_bytes: 262144.
file, filesystem operations
CRUD for files within a security boundary. Path traversal outside the boundary is rejected. Stateless.
Emission mode: atomic-emitting. One tool_result turn per operation (read/write/list/stat).
Body format of the response: messages[] with a tool_result turn. On read, text contains the file content (on large files the entire message is offloaded as Body::Blob at the delivery boundary, and never via an in-message text_id pointer). On write, list and stat, text contains a JSON-structured status (bytes written, file list, stat info).
Output header: operation ("read"/"write"/"list"/"stat"), bytes, duration_ms, optional encoding (only read with mode: "base64", GH #106).
params: base_path (mandatory, the security boundary).
Read modes and byte ranges (GH #106). By default read is a text read: text carries the file content, bytes its byte length, and a non-UTF-8 file is a typed io_error. Two optional arguments open up the rest.
mode:"text"(the default; absent andnullmean the same) or"base64". In base64 modetextis standard-alphabet base64 (RFC 4648 §4, padded) of the raw bytes, the emission additionally carriesheader.encoding: "base64", andheader.bytesstays the raw byte count instead of the encoded length. That makes a.pyc, an object file or a PNG header inspectable at all instead of merely refused. The encoder is hand-rolled (the closed tech-stack allow-list) and pinned against the RFC vectors.offset/limit: a window in BYTES, in either mode.offset>= 0 (default 0),limit>= 1 (default: the rest of the file);limit: 0and non-integer values areinvalid_input. A window running past the end is clamped, and anoffsetat or past the end is an empty read (bytes: 0) instead of an error, which is the “you are at the end” paging signal.- Byte semantics go past UTF-8: a window can land mid-character. In text mode that is the same typed
io_erroras any other non-UTF-8 read, and the text names the way out (mode: "base64"). Base64 mode does not have the problem, which is what it is for. - The three arguments belong to
read. Onwrite,listandstatthey areinvalid_inputinstead of being silently ignored, because a silently droppedoffseton awritewould let the caller believe in a partial write that never happened. - The default contract is untouched: without
mode,offsetandlimitthe old path runs exactly as before, including the absence of theencodingheader.
Conventions:
target = reply_to: FileCell emits tomsg.reply_to, with/colony/dead_lettersas the fallback ifreply_tois missing. Edges in the topology can override the target.tool_call.textis JSON args:{"op": "read"|"write"|"list"|"stat", "path": "<rel>", "content"?: "<str for write>", "mode"?: "text"|"base64", "offset"?: <u64>, "limit"?: <u64 >= 1>}(the last three forreadonly, GH #106).writewithout auto-mkdir: the parent dir MUST exist, and a missing parent isio_error. Symlink-safe via parent canonicalize.writeerror texts are a contract (GitHub #79):io_erroris ambiguous on the write path, sotextnames the condition and the parent as the caller wrote it:parent directory does not exist: notes (write does not create directories),parent path is not a directory: notes,parent directory not accessible: notes (permission denied). Failures of the write stage itself (after the parent resolved) carry the prefixwrite failed:plus the named reason (permission denied,read-only filesystem,no space left on device, …). Theerror_codestaysio_errorin every case, so the texts are the distinction and not the taxonomy.- Security boundary, two stages (GH #107): lexical first, canonicalized second. Stage 1 runs before any filesystem access, as a plain component walk over the relative path (
.is nothing, a name descends,..ascends), and climbing above the base ispath_outside_boundary, even if a later component would come back in (../<base-name>/x), because deciding that would mean resolving names outside the fence, which is exactly what is being closed. Stage 2 is unchanged:canonicalizeresolves symlinks and the canonical path must live underbase_path(on the write path, the canonical parent). The ordering is the point:canonicalizefailsnot_foundon a missing target, so../missingused to reportnot_foundwhile../existingreportedpath_outside_boundary, a (weak) existence oracle for the world outside. Now every escape attempt answers identically, whatever is or is not out there, on the read path and the write path (a missing parent outside the fence is an escape, not anio_error). A..inside the boundary stays ordinary path arithmetic. Absolute paths remaininvalid_input. - Default
max_concurrency: 8. error_codes:invalid_input,path_outside_boundary,not_found,not_a_directory,not_a_file,io_error.
edit, file-editing operations
Edits files within a security boundary, typically find/replace, insert-at-line and patch. Stateless.
Emission mode: atomic-emitting. One tool_result turn per edit operation.
Body format of the response: messages[] with a tool_result turn. text contains the status of the edit operation (“3 occurrences replaced”, a diff snippet). On an error (file not found, pattern does not match) the error is described structured in text, and header.error_code marks the class.
Output header: operation, matches_changed, bytes, duration_ms, optional error_code.
params: base_path (mandatory, the security boundary).
Conventions:
- Ops:
find_replaceandinsert_at_line. Patch is deferred, since it needs a separate diff-format design pass. find_replacereplaces ALL occurrences. Thematches_changedheader gives the count.- 0 matches gives
ERR_PATTERN_NOT_FOUND: the caller wanted to replace and the pattern was not there, so it is an error and not a normal tool_result withmatches_changed: 0. expected_matches, the expectation guard (GH #105): an optional argument offind_replace, an integer >= 1. When it is set and the actual match count differs, the file is not touched; the answer iserror_code: "unexpected_match_count"andtextnames both numbers, expected and found. The reason: replace-ALL with an ambiguous pattern silently patches sites the caller never saw, which is the highest-risk failure mode while coding. With the guard the count becomes a precondition instead of an after-the-fact report. Without the argument the behaviour is unchanged (replace-ALL,matches_changedas a report), andnullcounts as “not set”.expected_matches: 0isinvalid_input(the guard counts sites that are meant to change, and none of them is not an edit intent), as is a non-integer value. On precedence: 0 matches staypattern_not_found, guard or no guard, because “your pattern is not in this file” is a different repair from “your pattern is not unique enough”. Oninsert_at_linethe argument isinvalid_input, since there is no match count there and silently ignoring it would fake a guard that never runs.insert_at_lineis 1-based and inserts BEFORE:line = 1puts the content at the very start,line = file_lines + 1at the very end.line < 1orline > file_lines + 1isinvalid_input.contentis normalized to a whole line (GH #108): whencontentdoes not end in\n, the cell appends exactly one. Before,contentwas spliced verbatim between the line slices and, lacking its own terminator, fused with the line it displaced ("X"at line 2 of"a\nb\n"produced"a\nXb\n"), a silently broken file that only the next compile run reported. The operation is calledinsert_at_line, so the cell closes the line it is asked to insert. The alternative of just documenting it was rejected: the failure is silent at edit time, and documentation prevents nothing silent. Two edges: emptycontentstays empty (it starts no line, and a caller who wants a blank one writes"\n"), and the FILE’s own missing final newline is left alone, because appending to a file without a trailing\nstill lands on its last line and the opposite would rewrite a line the caller never named.- Shares FileCell’s security boundary: the same
base_pathlogic (extracted intomeclaw-cells/src/boundary.rs), including the two-stage fence from GH #107 (a lexical pre-check ahead of the existence check, see §file). - Not atomic: read-modify-write without tempfile plus rename, consistent with FileCell::write. A crash mid-way is an OS-level problem. Atomic edits are post-roadmap.
- Concurrent edit on the same file: a race condition is possible, since there is no lock. The caller topology serializes if needed.
- Input:
{"op": "find_replace", "path": "<rel>", "find": "<str>", "replace": "<str>", "expected_matches"?: <u64 >= 1>}{"op": "insert_at_line", "path": "<rel>", "line": <u32>, "content": "<str>"}
- Default
max_concurrency: 8. error_codes: reused from file, pluspattern_not_foundandunexpected_match_count(GH #105).
proxy, a bridge to an external chat platform
Long-running. Bridges to an external chat-platform provider. Since 0.1.12 there are two platform variants behind params.platform (optional, default "telegram", so every config written before 0.1.12 keeps parsing to exactly the same result): telegram (Bot API over HTTP long poll) and slack (Socket Mode, WebSocket push). One instance bridges exactly one platform. It holds a cursor for update offsets in cell.db, so that restarts do not process messages twice.
Concurrency setup: two Tokio tasks per instance (handler plus I/O), communicating over an internal mpsc (see meclaw-overview.md, section “Long-running cells: dual task”). From the topology’s view the cell stays a single address with a single external mailbox, and the dual structure is internal and prescribed for this cell type.
- The handler task does a
tokio::select!over the external mailbox (inbound from topology) and the internal channel (provider events from the I/O task). It holds the entire cell state (the cursor incell.db, in-memory session maps) and sets order and state mutations alone, with no Mutex. - The I/O task polls Telegram (long-poll or webhook reader), serializes incoming user messages into event frames and pushes them into the internal mpsc. It holds no cell state and has no direct
cell.dbaccess.
Emission mode: atomic-emitting towards topology. One external chat message from the user becomes one emitted meclaw message with exactly one user-origin turn. The proxy is the source of the conversation thread and never mid-stream, and it has no incoming messages[] to pass through.
Body format of the outbound message (Telegram to topology):
{
"messages": [
{ "origin": "user", "type": "text", "text": "<typed by the user>" }
]
}
Plus a header with platform metadata: chat_id, user_id, platform: "telegram", and an optional message_id (the platform’s own ID, pass-through for later replies).
Inbound behavior (topology to Telegram): the proxy consumes incoming meclaw messages, extracts the last assistant turn from messages[] and sends its text to the chat platform. In doing so it emits nothing back into the topology, being a pure sink. Routing to the right chat conversation runs via chat_id from the headers.
Inbound error paths: if the inbound body is not inline-readable (no inline UBF), the proxy emits error_code: "invalid_body". If the chat_id header is missing, error_code: "missing_chat_id" (fallback /colony/dead_letters). If messages[] contains no sendable assistant turn, error_code: "missing_assistant_turn". If the send to the chat platform fails (a network error, a Telegram API error, an invalid chat_id), error_code: "send_failed". If the cell.db write of a params update exceeds the A-timeout query_timeout_ms (via DbConn::call_with_timeout), error_code: "query_timeout", and the update is then not applied. All error replies go to msg.reply_to (fallback /colony/dead_letters) and carry a non-conversation origin (no user or assistant turn) and do not count as a conversation emission, so the pure-sink discipline (“emits nothing into the conversation flow”) stays intact.
params: typically platform credentials (the bot token via ${VAR}) and polling configuration (long-poll interval, timeout). Optional query_timeout_ms (the A-timeout for cell.db ops via DbConn::call_with_timeout, a cursor persist for instance).
Runtime param updates (β, config.md § Access L.20) work as for llm (see there): a top-level params body slot, persisted in the cell.db, replayed on wake or respawn. Mutable over all three propagation paths: send_timeout_ms (path A, handle-side, and the next sendMessage uses it); long_poll_timeout_ms, long_poll_request_secs and base_url (path B, where the handler signals the I/O task via an internal reconfig channel and the next poll uses them; on a base_url change handler and I/O task rebuild their TelegramClient live via with_base_url and retain the immutable bot_token from the existing state, so the token never crosses the params surface, and the tripwire long_poll_timeout_ms > long_poll_request_secs*1000 is re-enforced at merge); and query_timeout_ms (path C, the running DbConn). Immutable per proxy: bot_token and emit_to, being credential and routing identity. base_url is a config URL like llm.base_url and not a credential, so it is mutable. An update attempt on an immutable or an unknown key, or a violation of that tripwire, is a loud reject (error_code: "invalid_input") with no partial apply. A params-only message persists and stays silent.
The Slack variant (0.1.12) is a second platform of the same cell type and not a new cell type. It is enabled via params.platform: "slack". One instance is one Slack app, one bot token, one bot identity.
-
params:app_token(the app-level tokenxapp-…, forapps.connections.open) andbot_token(the bot tokenxoxb-…, forchat.postMessage) are required, are supplied only as${VAR}, are immutable, and are redacted inDebug, in logs and in error messages, because thebot_tokenIS the bot’s identity in the workspace. Alongside thememit_to(required),base_url(defaulthttps://slack.com/api), the A-timeoutsconnect_timeout_ms/send_timeout_ms/query_timeout_ms,idle_timeout_ms(the idle deadline of the socket mode read loop, default120000= four missed pings at Slack’s slowest documented cadence; every frame resets the deadline and an elapsed one becomesConnectionEnd::Transientfeeding the reconnect machinery),envelope_dedup_secs(retention of theseen_envelopesdedup table) andthread_follow(defaulttrue). Optionalbot_user_id(U…), used solely for the defensive self-filter R4. -
Socket Mode instead of long poll: the I/O task fetches a short-lived
wss://URL viaapps.connections.open, connects, and waits for thehelloframe, which carries our ownapp_id, the input value for R3. After that the loop is purely frame-driven: no tick, no interval, no “check whether anything arrived” (the standing rule NO POLLING). A reconnect happens exclusively on an event (adisconnectframe, a WS close, a stream error), and the backoff only damps the failure case before the next connection attempt and is never a query cycle. -
Acknowledgement duty: the ack goes out immediately after the frame is decoded, before any filter and before the handler ever sees the event. Silence is not the same as ignoring, since an unacknowledged envelope gets redelivered by Slack. A crash between ack and persist loses at most one event, the same trade the Telegram path already made with state-before-emit. Against network-level redeliveries the handler deduplicates on the
envelope_id(cell.dbtableseen_envelopes). -
chat_idis a composite STRING:"C…"for a DM, or"C…:<thread_ts>"inside a thread, a documented convention whose thread part is optional. Emit path and reply path share exactly one build-and-split function, so the two spellings cannot drift apart. Additionally in thehop:platform: "slack",slack_channel,slack_thread_ts,slack_event_ts. -
Addressing: a mention in the channel root opens a thread at its own
ts, and every answer runs into the opened thread. A mention inside a thread stays in that thread, and a DM stays threadless. A follow-up without a mention is processed only when the cell owns the thread (thethread_ownertable incell.db, switchthread_follow). A bot never answers inside a foreign thread, not even when the ownership check fails on a database error. -
Loop guard R1 to R5, on by default; R1 to R4 stateless in the I/O task, R5 in the handler:
Rule Condition Effect R1 event.bot_idpresentignore R2 event.subtype == "bot_message"ignore R3 event.app_id== our ownapp_id(from thehelloframe)ignore R4 event.user==params.bot_user_id(when set)ignore R5 a channel messagewithout a mention and without thread ownershipignore Ignored events are acknowledged anyway.
A live lesson (2026-08-09, against the real Slack API):
app_mentionis routed selectively to the mentioned app, whereasmessage.channelsreaches every subscribed app. Every channel message therefore arrives at the bots twice or more, carrying the sametsbut differentenvelope_ids, which is exactly why the envelope dedup does not catch it. The guard is thus a correctness condition and not politeness: without it a bot emits the same user message twice into the agent tree, and every bot answers the mentions addressed to all the others.Bot detection runs on
bot_id, which Slack sets together withbot_profileon bot-authored messages, and not onsubtype:subtype: "bot_message"is classic-app behaviour, unreliable on its own, and stays only a second line of defence. R3 readsevent.app_id, the sending app, neverpayload.api_app_id, which names the receiving app, equals our own on every inbound event, and would discard all traffic. -
Beta asymmetry towards Telegram: the Slack variant accepts no runtime param updates today. It builds exclusively from the birth params, and there is neither an overlay restore nor a
paramsupdate path in the handler. Recorded as a deferred item (“β params overlay for the Slack variant”).
timer, a periodic event emitter
Long-running. A cron-like scheduling cell. It holds the active schedule list in cell.db. The cron format is 6-field Quartz style (Second Minute Hour DayOfMonth Month DayOfWeek), so that second granularity is natively expressible. The scheduler resolution is correspondingly second-accurate, and firing happens exactly at the configured second, with no polling grid — the next occurrence is computed from the second that is running and not from the fraction in which the previous firing finished, so the firing time does not drift with the wake latency (GH #626). It can send one-off as well as repeating events.
Concurrency setup: two Tokio tasks per instance (handler plus I/O), communicating over an internal mpsc (see meclaw-overview.md, section “Long-running cells: dual task”). Prescribed for this cell type, not optional.
- The handler task does a
tokio::select!over the external mailbox (schedule creation, modification, deletion) and the internal channel (timer firings from the I/O task). It holds the in-memory schedule list, persists it tocell.db, and sets order and state mutations alone. - The I/O task computes the next-due schedule entries, waits for them with
tokio::time::sleep_until, pushes one firing event frame per entry into the internal mpsc, and computes the next wait point. On schedule changes (add, modify, remove) the handler task sends a reconfigure hint to the I/O task, which redoes its sleep computation. It holds no cell state and has no directcell.dbaccess.
What a timer is NOT for (GH #553). A timer expresses real time semantics: a nightly close, a housekeeping window, a digest at seven. It is not the answer to “has anything changed?”, which is a poll, and a poll in an event-driven substrate spends availability on an answer that is already settled. Two shipped templates did exactly that (collector/menu-clock asked for tool declarations every five minutes, colony-view/refresh asked for the topology every minute), and the mutation door answers both questions by itself: it leaves one mutation receipt per committed knock (colony.json mutation_receipts.to, meclaw-overview.md § colony.json), and the boot is the first one. So whoever writes a timer checks first whether the event has a name; if it has one, the edge is the answer and not the clock.
Emission mode: atomic-emitting. The timer produces no content of its own. It sends what was passed along as the body template at schedule creation, at the configured time.
Schedule identity: each schedule has a schedule_id (UUID v7) as a unique key, assigned by the caller in the creation message (or in the params.schedules entry at instantiation). schedule_name, by contrast, is a non-unique human-readable label, may occur several times, and serves only readability and the fire header. Modification and deletion always address via schedule_id, never via schedule_name.
Where the op lives, two admitted places (GitHub #81): either as top-level slots of the body (the form for config-born ops, for the HTTP ingress, and for any cell feeding the timer directly) or as structured JSON args in the tool_call turn (analogous to store and bash, see there). If the body carries a tool_call turn, that turn wins, and its own parse errors are reported instead of falling back to the top level and pointing at the wrong level of the message. This makes the timer usable as a tool lane without a bridge cell: the dispatcher unwraps {name, arguments} into a tool_call turn, the timer reads the args like every other tool cell, and answers with a tool_result on the same id (see “Ack” below).
One operation per message, via the mandatory field op: "add" | "modify" | "remove" | "trigger" (default add, if omitted):
{
"op": "add",
"schedule_id": "0190a3f2-...-v7",
"schedule_name": "daily-standup",
"cron": "0 0 9 * * *",
"emit_to": "/main/standup_hive",
"emit_body": { "messages": [{ "origin": "user", "type": "text", "text": "..." }] },
"emit_headers": { "msg_type": "standup_trigger" }
}
{ "op": "remove", "schedule_id": "0190a3f2-...-v7" }
{ "op": "trigger", "schedule_id": "0190a3f2-...-v7" }
modify carries schedule_id plus the fields to change (a new cron, say).
Semantics, strict and with no heuristic:
addis an INSERT; an existingschedule_idis an error, with no implicit upsert.modifyis an UPDATE of the carried fields; an unknownschedule_idis an error.removedeactivates the schedule (a status update incell.db, No-Delete-conformant, with no row deletion); an unknownschedule_idis an error.triggerfires an existing schedule once, now, without changing its plan (GitHub #17). The op carries nothing but theschedule_id, and everything else (emit_to,emit_body,emit_headers) comes from the row, because the schedule already IS the description of what is to be fired. An unknown or non-active (removed,completed)schedule_idis an error.
What trigger delivers is the firing itself and not a similar one: the handler checks only existence and status and hands the firing to the I/O task, which pushes the same fire frame the sleep_until arm pushes. Everything after that is identical: the same race check, the same state-before-emit (an iteration_n bump or mark_completed), the same OriginSink emission with the full auto-header set. A triggered repeating schedule counts its iteration_n on as usual and keeps its next cron occurrence; a triggered one-off counts as completed afterwards and no longer fires at its own at (the race check in handle_event). The op itself writes nothing to the schedule and emits nothing, which is why a triggered run is indistinguishable from a cron-fired one.
Validation and error surfacing: on add and modify a cron expression is validated against the 6-field Quartz parser. Invalid expressions are rejected, so no silently stored, never-firing schedule arises. All op errors are emitted as a message to the reply_to of the op message (parent_message_id = the consumed op message), with header.error_code for: invalid_body (the body is not inline-readable), parse_error (the op message is unparsable beyond the cron check), schedule_id_exists (an add on an existing schedule_id that names another order; since GH #690 an add that repeats an active schedule with the same moment is acknowledged and changes nothing, and an add on a removed row of the same id revives it in place; the comparison reads kind and moment only, so a repeated add with the same id and moment but another emission keeps the standing emission, and a completed order is not revived: a manual trigger before its moment followed by the same order again answers schedule_id_exists), schedule_not_found (a modify, remove or trigger on an unknown schedule_id, and a trigger on a non-active one), kind_mismatch (a modify switching type between once and repeating), invalid_cron (an invalid cron expression), query_timeout (the A-timeout query_timeout_ms via DbConn::call_with_timeout; it wraps every cell.db op of this cell, but it is only reported on the op path, meaning the persistence of a params update as well as the schedule ops add/modify/remove/trigger, which are then not applied. On the fire path the same timeout is silent: the tick is dropped with a debug log and no message goes out) and at_in_past (GH #231: a one-shot schedule whose at has already passed by the time the op arrives). at_in_past is the same thought as the cron check one line deeper: the cell fires nothing retroactively, so an accepted past at would be exactly the silently stored, never-firing schedule this paragraph rules out. The boundary is structural (at <= now, the same one the plan filter uses on a restart) and needs no chosen threshold, and the guard and the op’s snapshot share one clock for it.
Two parse_error forms are separated (GitHub #81): “no op object at the body top level” (the body carries only carrier slots such as messages, and the message names them and the two admitted places) and “the op object is there, its schedule_id is not”. The first is the answer to a message in which the op never arrived, and calling that a missing schedule_id points at the wrong field.
Ack (GitHub #81): if the op arrived as a tool_call turn, add, modify, remove and trigger answer on success with a message to reply_to (fallback msg.target) carrying exactly one tool_result turn with the inbound tool_call id; the header carries msg_type: "timer_op_ack", op and schedule_id. Errors on the same lane carry the same turn plus finish_reason: "error", so a tool loop closes on the failure path too instead of waiting for a result that never comes. Without an inbound tool_call id nothing changes: successful ops on the raw-body path stay unacked, and its errors keep their shape (messages: [] plus meta.detail). The firing itself is untouched by this, since it goes via OriginSink to emit_to and not to the caller. Runnable example: tests/fixtures/gh81-remind-lane/ (dispatcher, timer, ack and fire lanes), pinned in crates/meclaw-cli/tests/gh81_remind_lane_e2e.rs.
Op messages over the HTTP API: the op body is an ordinary UBF body, with the op fields as cell-specific top-level slots. Whoever feeds an op in through POST /messages additionally declares the central slot the message honestly has: an op message carries no conversation turns, hence "messages": []. Without a central slot the ingress validation rejects it with 422 invalid_ubf_body (overview § Schema validation, edge). Example:
{ "target": "/main/nightly",
"body": { "messages": [], "op": "trigger", "schedule_id": "0190a3f2-...-v7" } }
The op stays colony-validated throughout: the HTTP layer checks the envelope, and the cell checks the op (schedule_not_found, invalid_cron, …). A scheduled lane is therefore triggerable once from outside, without restarting the colony and without writing past the timer’s cell.db (GitHub #17).
The example addresses the timer directly, and that works as long as it does not stand behind a sealed hive’s boundary. Where it does — any template with params.ports — its path is no address from outside: since GH #612 it is refused with hive_boundary (meclaw-overview.md § The hive boundary). The level then draws the lane and the caller addresses the level.
On timezones: cron expressions are evaluated in UTC (chrono::Utc::now() in crates/meclaw-cells/src/timer/io.rs). There is no timezone parameter and no local-time resolution, so 0 0 9 * * * fires at 09:00 UTC and not at 09:00 local. Whoever wants local time folds the offset into the expression themselves, and carries themselves that a daylight-saving switch moves it.
One-off against repeating: a repeating schedule carries cron (6-field Quartz). A one-off one carries at instead (RFC-3339-Z, UTC) and no cron. The fields are exclusive, exactly one per schedule. iteration_n is emitted only on repeating schedules and omitted on once. modify may not switch the type between once and repeating; for that, use remove plus add.
{ "op": "add", "schedule_id": "0190a3f2-...-v7", "schedule_name": "one-shot-reminder", "at": "2026-06-01T09:00:00Z", "emit_to": "/main/x", "emit_body": { "messages": [] } }
Past firings are discarded (POC behavior): the timer plans exclusively the next firing after now (find_next_occurrence). A one-off schedule whose time already lies in the past, at creation or restart time, is not scheduled and only logged. Repeating schedules do not catch up missed firings; they fire from the next future occurrence. The rationale is that the timer has no relevance or priority classification and cannot decide whether a missed event is still to be delivered.
Ties (GH #613): if several schedules of the same cell are due at the same second, all of them fire, each exactly once, in schedule order — the order in which they were created in cell.db. None of them counts as a missed firing.
The body can contain arbitrary universal body slots: messages[], own top-level slots, or nothing at all (a header trigger only).
Headers emitted on schedule firing, timer-automatic and in addition to emit_headers:
| Header | Content |
|---|---|
event_id | UUID v7 of this single event |
schedule_id | unique UUID-v7 key of the triggering schedule |
schedule_name | human-readable label of the schedule |
scheduled_at | planned time (RFC-3339-Z, UTC) |
fired_at | actual fire time (RFC-3339-Z, UTC) |
iteration_n | on repeating schedules: 0, 1, 2, … |
A contract quirk: emits.body is wildcard-like (whatever the schedule defines), while emits.header is strictly the fixed set above, plus what the schedule passes under emit_headers.
params: typically none. Schedules are created at runtime per message, or optionally initially via params.schedules. params.schedules entries carry the same schema (each with schedule_id as UUID v7), and the initial seed takes effect only on a fresh cell.db (the OpenStatus::Created gate, analogous to the store seed). Otherwise each restart would re-seed the config schedules into duplicates. Optional query_timeout_ms (default 5000) sets the A-timeout for cell.db accesses (rusqlite InterruptHandle via DbConn). It applies to all cell.db ops of the cell (add/modify/remove plus the fire-side reads and writes) that run via DbConn::call_with_timeout.
Runtime param updates (β, config.md § Access L.20) work as for llm (see there): a top-level params body slot, persisted in the cell.db, replayed on wake or respawn. The only overlay-capable field is query_timeout_ms, and it takes effect immediately live, since the running DbConn adopts the new A-timeout for the next cell.db op without a wake or respawn. schedules are not overlay-capable: they change exclusively via the add, modify and remove ops, because they carry live state (status, iteration_n) in the cell.db. The immutable set is empty; an update on schedules or on an unknown key is a loud reject (error_code: "invalid_input"). A params-only message persists and stays silent.
mcp, a bridge to an MCP provider
Long-running. Bridges to an external MCP provider (Model Context Protocol). Holds states in cell.db as applicable (a tool-discovery cache, session handles). There are two transports: http (HTTP plus JSON-RPC, a fresh connect per call; initialize / tools/list / tools/call) and stdio (a child process, line JSON over stdin and stdout, since 0.1.7). params.transport is optional and defaults to http. Server-pushed notifications, SSE and auto-reconnect remain a roadmap defer.
Concurrency setup: two Tokio tasks per instance (handler plus I/O), communicating over an internal mpsc (see meclaw-overview.md, section “Long-running cells: dual task”). Prescribed for this cell type, not optional.
- The handler task does a
tokio::select!over the external mailbox (tool-call requests from the topology, discovery requests) and the internal channel (server-pushed events or tool responses from the I/O task). It holds the entire cell state (the discovery cache, session handles, the in-flight map of correlated tool calls). - The I/O task talks to the MCP provider: on
httpover HTTP plus JSON-RPC with no persistent stream, and onstdioit owns the child process entirely and holds the long-running stream read, while the handler holds no pipe and talks to it over the internal reconfig channel. It serializes responses into event frames and pushes them into the internal mpsc. It holds no cell state and has no directcell.dbaccess.
Sandbox: see params.sandbox below. Since GH #96 this cell’s stdio transport reads the same profile as bash, code and harness. Without the block the historical behaviour stands, and the child process runs with the colony daemon’s rights.
Post-init backend death: on http the cell holds no persistent connection, and every tool call connects anew. If the MCP backend dies transiently after the discovery, the cell therefore recovers automatically on the next tool call, since the fresh connect succeeds again; a permanently dead backend manifests per call as provider_timeout or mcp_error. Death detection between calls does not exist on http (run_http_io pends after the discovery; a roadmap defer, with SSE build-out as the trigger). On stdio the stream read carries the liveness signal: on EOF or exit an open call first receives a regular error message (mcp_error, whose detail names exit code, signal or EOF), then the cell panics into one_for_one with a fresh child process, and after restart_limit the registry entry is retained as failed. No new error_code.
params.sandbox (optional, GH #96) is the same profile schema as bash, code and harness: one profile, one parser, one set of mistakes an operator can make. It applies to the stdio transport only, since on http there is no child process to contain. It is opt-in: without the key the child keeps the daemon’s rights, which is the historical behaviour and the state of every mcp cell installed today. The key is immutable, because a boundary a runtime params update could switch off is not a boundary (the same argument as store.write_surface).
Of the three places in the tree that start a foreign process, this is the strongest case for a profile: an MCP server is a third-party binary an operator configured, and therefore the one least likely to have been written by whoever runs the colony. A reasonable profile gives it its own installation directory plus whatever it serves, and nothing else.
Emission mode: atomic-emitting. One response message per MCP tool call, with the result as a turn.
Body format of the response: messages[] with a tool_result turn, whose text contains the MCP tool answer, typically JSON-structured. On large answers the entire message is offloaded as Body::Blob at the delivery boundary, and never via an in-message text_id pointer.
Discovery: MCP tools that this provider offers are made available via a discovery message. The cell can play out its system.tools.* slots to an llm cell, so that the latter presents the tools to the LLM. The exact mechanism is not fixed here.
Output header: mcp_tool (the name of the called tool), duration_ms, optional error_code. The canonical mcp error_code values are "mcp_error" (a JSON-RPC or protocol error of the provider, a tools/call error response for instance), "provider_timeout" (external_timeout_ms elapsed at the provider call, on both transports), "query_timeout" (the A-timeout query_timeout_ms around the cell.db write of a params update, via DbConn::call_with_timeout, after which the update is not applied) and "endpoint_unset" (GH #489, see below: no provider was named and nothing was called).
There is no finish_reason here. This cell writes none, neither on the success nor on the error path: its header carries mcp_tool, duration_ms and, on failure, error_code, and nothing else. A topology that conditions a failover edge on hop.finish_reason == 'error' therefore never fires for this cell type. The code such an edge has to read here is hop.error_code.
No endpoint means no provider, and that is a state rather than an error (GH #489). If endpoint is absent on the http transport, or present and empty (${MCP_ENDPOINT:-} with the variable unset), the bridge has no far side. The cell still spawns, with a mailbox, a cell.db, params updates and all, and runs no handshake, holds no client and does not panic. Every round that reaches it is answered with error_code: "endpoint_unset", the discovery round __list_tools__ included, because an empty tool listing and a forgotten call must never look alike. An empty value and an absent one land in the same state, keeping GH #270’s rule, and only that state is a named idleness now instead of a spawn refusal. The stdio transport keeps the loud reject for an absent or empty command: whoever declares transport: "stdio" names a binary or names nothing. To delimit this against the panic path above: an endpoint that WAS named and does not answer still panics five times and is then retained as failed in the registry, because that is a real fault and the panic is its supervision signal.
params: transport (optional, "http" default or "stdio"); on http the provider endpoint (an HTTP URL for JSON-RPC) plus auth credentials (via ${VAR}); on stdio command (required) plus optional args, env, cwd and kill_grace_ms (default 2000). endpoint and command at the same time is a loud reject. Plus discovery configuration, optional external_timeout_ms (an A-timeout, error_code: "provider_timeout") and query_timeout_ms (an A-timeout for cell.db ops via DbConn::call_with_timeout).
Runtime param updates (β, config.md § Access L.20) work as for llm (see there): a top-level params body slot, persisted in the cell.db, replayed on wake or respawn. Mutable: external_timeout_ms, which takes effect immediately live (path A, and the next call_tool uses it; the I/O task has no live-re-readable value post-discovery, hence purely handle-side), and query_timeout_ms (path C, where the running DbConn adopts the new A-timeout for the next cell.db op). Immutable per mcp: endpoint and auth (bearer), being credential and identity, as well as transport, command, args, env, cwd and kill_grace_ms, being the process identity of the child. An update attempt on one of them or on an unknown key is a loud reject (error_code: "invalid_input") with no partial apply. A params-only message persists and stays silent.
harness, an agent harness as a supervised child process
Long-running. Operates a full agent harness (today: Claude Code in print mode) as a supervised child process out of the topology. The harness pre-prompts, loops and uses its own tools, because the job here is delegating whole coding tasks instead of single model calls. One child process per task: session continuity comes from the harness’s --resume and not from process lifetime.
Concurrency setup: two Tokio tasks per instance (handler plus I/O), prescribed, see meclaw-overview.md, section “Long-running cells: dual task”. The I/O task owns the child process entirely (on the stdio_child core, like mcp stdio); the handler owns the task register and the emissions. Exactly one task at a time per cell, since parallelism is a topology matter (several cells, each with its own worktree).
Task lifecycle: Booted, idle, start_task, spawn, frame stream, child end, idle. An ending child process is the normal case here and does not panic the cell, which is the counter-semantics to mcp stdio, where child death ends the cell.
Non-idempotency (a core invariant): the cell.db table harness_tasks is a tombstone register. The row is running before the spawn; after a supervisor restart every unfinished row is set to unknown and reported exactly once as unknown_outcome (“inspect worktree”), and never restarted. A task_id runs exactly once (dedup), so task_id is a required input and not a generated fallback.
Emission mode: long-running, stateful.
Body format of the emissions: five forms. accepted (synchronous as a tool_result in the trace of the triggering message, carrying the task_id as the anchor), and progress, question, result and error (origin emissions to params.emit_to, each with a fresh trace, correlated via header.task_id).
Output header: harness_event, task_id, session_id, status, workspace, duration_ms, num_turns, cost_usd, model, phase, tool_name, request_id, error_code. The header carries only what was observed, so there is no branch and no commit: the harness’s self-report stays prose in the turn, and verification (tests, diff inspection) is a follow-up step of the topology.
Failure classification (error_code, closed): invalid_input, harness_busy, workspace_invalid, spawn_failed, startup_timeout, harness_crashed, cancelled, unknown_outcome, query_timeout.
Precedence when two of them apply (pinned): a start_task is checked in the order occupancy, workspace, tombstone, and the first refusal is the one reported. The case that makes this visible is a repeated task_id arriving while a task runs: both harness_busy and the dedup rejection hold, and harness_busy wins. That order is not incidental, because the dedup verdict comes out of the tombstone INSERT, which is the same statement that claims the slot, so deciding dedup first would mean an extra read on every start to change nothing but a label. The same payload answers invalid_input once the harness is free again. Pin: busy_beats_dedup_when_both_apply.
Cancel: a cancel message (with task_id) sets the tombstone to cancelled before the child is stopped (a process-group kill including grandchildren) and emits the task end marked cancelled; the cell accepts new tasks afterwards. Cancel is the stop lever for the unbounded task runtime (see overview § Timeouts).
params: adapter (required; today only "claude-code"), emit_to (required), workspace_root (required, canonicalized, and tasks run only below it), command, model (from ${VAR}), permission_mode, max_turns, max_budget_usd, allowed_tools, extra_args, env, env_passthrough, approval (off or channel), startup_timeout_ms, external_timeout_ms, query_timeout_ms, kill_grace_ms.
Sandbox (GH #85): harness reads the same params.sandbox block as bash and code (schema in config.md § params) and hands it to the stdio child process. It sits next to env_clear/env_passthrough and the canonicalized cwd clamp instead of in their place, because the three answer different questions. Process-group and reaping semantics are unchanged, and a sandboxed child still leads its own group. sandbox is on the runtime overlay’s immutable list: a params update touching it is rejected as Immutable. A harness cell instantiated from a template without a block of its own gets the default-deny profile, so a harness that is supposed to write a workspace declares one, or takes an explicit trust: "trusted".
Trust model (empirically established 2026-08-09, the state before GH #85): harness is no sandbox by itself. The harness brings its own tools (shell, file access, network) and runs with the rights of the colony process. The load-bearing V1 barriers are env_clear plus env_passthrough (the harness does not see the colony’s secrets) and the canonicalized cwd clamp under workspace_root. allowed_tools is explicitly NOT an upper bound: the CLI treats --allowedTools additively to what the permission mode allows anyway, and in the acceptance smoke Bash ran despite allowed_tools: ["Write"]. allowed_tools extends, it does not restrict. permission_mode is no upper bound either (measured 2026-08-21 against CLI 2.1.237, GH #46.2): with --allowedTools omitted entirely, Bash ran under --permission-mode default as well as under --permission-mode plan, and both runs ended status: ok and returned the shell output. So neither of the two knobs the cell type offers today yielded a measured upper bound; --disallowedTools and --tools have not been measured and are therefore not recommended here. The only load-bearing boundary remains the one named above: env_clear/env_passthrough, the cwd clamp, and since GH #85 the sandbox block. Since GH #85: without params.sandbox a hand-written harness cell still runs exactly like that, and with the block it runs under the same boundary as code and bash.
approval: "channel" is a fixture promise today and not a measured runtime promise (GH #46.1, measured 2026-08-21 against CLI 2.1.237). The claude-code adapter’s control path (control_request/can_use_tool into a question emission, then answer into control_response) is proven against the fixture; the real CLI never sent a control_request in either run and simply used the tool. What was measured is only the absence of the question. The plausible explanation, and it is not measured, is the invocation shape: the adapter starts the child with --output-format stream-json and without the counterpart --input-format stream-json. Likewise only observed, and not causally established, is the child’s own message no stdin data received in 3s, proceeding without it: stdin is read once at startup and not held open as a control lane. Anyone who needs a hard tool gate today does not rely on approval; they use params.sandbox. Whether the adapter should gain --input-format stream-json, and whether there should be a dedicated upper-bound param, are two open decisions in the defer register.
Runtime param updates (β): mutable are model, max_turns, max_budget_usd, startup_timeout_ms, external_timeout_ms and query_timeout_ms, which take effect from the next task. Immutable, and a loud reject (invalid_input): adapter, command, emit_to, workspace_root, env, env_passthrough, permission_mode, allowed_tools, extra_args, approval, kill_grace_ms. That is the containment boundary. A params-only message persists and stays silent.
subcolony, a child colony as one cell
Long-running. Operates a complete child colony as one cell in the parent graph. The child is a real meclaw binary with its own {root}, its own colony.json, its own colony.db and its own cell tree, supervised as a child process on the stdin/stdout bridge in JSON mode (--stdio-format json, wire v1, see meclaw-overview.md § Stdin/stdout bridge). There is no in-process nesting: a colony stays one process with one tree, and nesting happens across the process boundary. The facade is therefore an opaque composition boundary, and from the outside the child colony is exactly one addressable cell.
Concurrency setup: two Tokio tasks per instance (handler plus I/O), prescribed, see meclaw-overview.md, section “Long-running cells: dual task”. The I/O task owns the child process entirely (on the stdio_child core, like mcp stdio and harness); the handler owns the request path and the emissions. The cell holds no lock and no shared state: the pending requests live in the serve loop, and the cell state in the handler task.
Boot handshake: spawn (--root <root> --stdio-format json, env_clear, its own process group; --daemon and --api are never set, because stdin EOF must end the child), then the child writes exactly one ready frame, after its bootstrap succeeded and before it reads stdin. boot_timeout_ms clamps the A-timeout on that frame. v is the protocol integer and is asserted strictly; version is the child’s release version and is reported only. Version skew between parent and child is intended.
Boot failures and restart cost: deterministic boot failures (a foreign protocol, an absent ready, a spawn failure) do not panic. The cell stays up and rejects every request loudly with the reason, so the restart budget is not burned on a certainty. Only transient child death goes into one_for_one. One restart cycle costs a full child-colony boot, and boot_timeout_ms is the upper bound of that cost and therefore the quantity to reckon with in the context of cell.restart_limit.
No automatic re-fire path: in-flight requests fail loudly with subcolony_gone when the child dies, and a retry is the requester’s decision and never the substrate’s. A request is explicitly not free to repeat, since it may already have triggered store writes inside the child.
This cell type takes no params.sandbox, and that is the ruling rather than an omission (#96, decided). A child colony is a colony and not a cell running foreign code: its cells carry their own profiles, so a profile here would be a second boundary over the same processes, and the two would disagree the first time somebody tightened one of them. The half that looks most useful does not survive contact either: a filesystem cut cannot be scoped, because the child needs its own root plus every cell directory below it, which is most of what it could be denied.
So the child process runs with the rights of the colony daemon, minus two things that are not nothing: it runs in its own process group (no orphan survives the parent) and with env_clear (it sees the declared passthrough list, not this colony’s environment). An untrusted child colony still belongs on a machine you do not mind. mcp, the other consumer of the shared stdio child, went the other way in 0.10.6 and now reads params.sandbox; the difference is that an MCP server is a third-party binary, while a child colony is this substrate running its own cells.
Consume: any UBF body with messages[]. It takes no tool_call wrapper, because a sub-colony is an ordinary cell in the flow (llm-shaped) and not a tool cell. That is the operational meaning of “behaves like ONE cell”.
Emit, three forms:
| Form | Lane | Target | Body |
|---|---|---|---|
reply | OutputSink (requester’s trace) | msg.reply_to ?? msg.target | {"header":{"subcolony_event":"reply"},"messages":[…from the child…]} |
error | OutputSink (requester’s trace) | msg.reply_to ?? msg.target | {"header":{"subcolony_event":"error","error_code":…},"messages":[{"origin":"assistant","type":"text","text":<detail>}]} |
unsolicited | OriginSink (fresh trace) | params.emit_to (only when set) | {"header":{"subcolony_event":"unsolicited"},"messages":[…from the child…]} |
Body discipline as with harness: everything structural goes into the header slot, and the turn carries text only.
Headers across the process boundary: only the body crosses the process boundary, and hop never crosses, in either direction. The header slot of a child emission is lifted into the hop compartment inside the child already and is consumed there. A parent edge therefore conditions on hop.subcolony_event, which the facade sets itself, and a child that wants to signal more says it in the body.
Failure classification (error_code, closed): subcolony_unavailable (spawn or boot failed), protocol_mismatch (a foreign protocol integer in the ready frame), boot_timeout, request_timeout, subcolony_gone (the child died during the request or is shutting down), ttl_exhausted, invalid_input (a body without messages[]), child_error (an error frame from the child), query_timeout (the A-timeout query_timeout_ms around the cell.db write of a params update, via DbConn::call_with_timeout, after which the update is not applied).
Trace and TTL: trace_id is carried across the boundary and not regenerated, so a trace runs through the child colony and stays correlatable. ttl is decremented on the crossing (ttl - 1), and at ttl == 0 there is no crossing but an error emission ttl_exhausted. TTL is thus the recursion budget of the composition: a child that calls the parent facade back dies like any other routing loop. The correlation key of request and reply is a context.turn_id freshly generated per request, since the parent message’s own would not be unique under fan-out; turn_id is therefore a reserved target key in the context_in mapping and is rejected loudly at params parse time.
Opacity: the child tree is not addressable from the outside. No path reaches through to a cell inside the child, and the context of the child’s reply stays in the child, since the reply travels in the parent requester’s trace. Mutations of the child tree run exclusively over the child’s own operator surface (its /colony/mutations) and never over the parent mutation path. This is composition, not federation.
Contract drift (operator responsibility): the facade’s contract lives in the parent config.json (consumes/emits as with every cell type). The boot handshake asserts only what it can assert cheaply, the protocol integer and the existence of the ready frame. Whether the child’s reality matches the parent’s declaration is operator responsibility and is not checked by the substrate; a child-published port manifest is a roadmap defer.
params:
| Key | Type | Default | Mutable (β) | Meaning |
|---|---|---|---|---|
root | string | required | no | Filesystem root of the child colony. Canonicalized at parse time (existence and is_dir), like harness.workspace_root |
command | string | "meclaw" | no | The child binary. Explicitly configurable, so version skew is a config decision |
env | object | {} | no | Explicit environment of the child |
env_passthrough | array | ["PATH","HOME","USER","LANG","TERM"] | no | Survives env_clear: true, the secret isolation of the child colony |
context_in | object | {} | no | Explicit mapping from a parent context key to a child context key. By default nothing crosses the boundary. turn_id as a target is a loud reject |
emit_to | string | none | no | Optional origin lane for uncorrelated child egress frames |
boot_timeout_ms | u64 | 30000 | yes | A-timeout on the ready frame |
request_timeout_ms | u64 | 120000 | yes | A-timeout on the correlated reply. Generous, because a child colony may contain an llm cell |
external_timeout_ms | u64 | 30000 | yes | A-timeout around every stdin write |
query_timeout_ms | u64 | 5000 | yes | A-timeout for cell.db ops |
kill_grace_ms | u64 | 5000 | no | SIGTERM to SIGKILL grace of the child process group |
The immutability boundary is the containment boundary, the same line as with harness: root, command, env, env_passthrough, context_in, emit_to and kill_grace_ms are immutable, and an update attempt on them or on an unknown key is a loud reject (error_code: "invalid_input") with no partial apply. A params-only message persists and stays silent.
Rule 12 (timeouts): cell.message_timeout (concept B, the substrate backstop) must sit clearly above request_timeout_ms (concept A), since otherwise the backstop fires before the facade can report its typed request_timeout. A convention as with all cell types, not enforced in code; see meclaw-overview.md § Timeouts.
Emission mode: long-running, stateful, atomic-emitting.
vault, a sealed secret store with no read
The promise is the type itself. This cell type’s route surface contains no get. It contains put, rotate, use, revoke, status, unlock, lock and deliver, eight operations and nothing else. A fully compromised model at the other end of an edge can ask the vault to use a secret inside a granted scope, and it can ask for a sealed delivery that only the recipient can open. It cannot ask to see one, because the question has no name here. deliver hands out no plaintext either: what leaves the vault is a box bound to an ephemeral key of the requester.
Two callers, and they may do different things:
| caller | recognised by | may |
|---|---|---|
| user channel | reply_to == None, a source message. No edge can produce one, because the colony stamps reply_to on everything a cell emits | put, rotate, unlock, lock, status, revoke |
| broker | reply_to == params.broker | use, status, revoke, deliver |
| everyone else | none | nothing. Refused before the operation is looked at, with an audit row |
That the broker may not put is deliberate: otherwise an agent that had captured the broker could swap the vault’s contents for its own.
The grant check belongs to the broker and not to the vault. A cell cannot query another cell inside one handle(), which is the actor model and not a limitation of this type. So the work is split where it can be done: the broker validates the grant against the grants store on its own lane, and the vault does the one thing only it can do, checking who is talking, and records the grant_id it was handed.
Injection at unlock is removed (GH #428). Until GH #421, params.inject_map was the one way a plaintext secret left the vault: at unlock every named secret went as a params_update to a cell named in the configuration. The sealed delivery superseded it and deprecated it, and measuring it afterwards finished it off. The push never worked at all: the emission is a bare params body, the UBF schema requires one of system/messages/attachments, so the colony discarded it as InvalidUbfBody and it died in the dead-letter queue before a single message_log row was ever written. A path with no users and no working delivery is not worth a migration, so it was removed instead of repaired. An old config still carrying the key now fails loudly at spawn (an unknown param) instead of silently doing nothing. What fetches the value of a credential out of the vault today is deliver, and nothing else (see “Sealed delivery”).
Sealed delivery (GH #421). The case that genuinely needs the value, a cell authenticating to a platform, is pulled since GH #421 instead of pushed, and encrypted on the way. The recipient mints an ephemeral X25519 pair per request, sends the public half through the policy-gated broker path, and the vault answers with a sealed box: an X25519 agreement against that public key, an HMAC-SHA256 over it for the box key, then XChaCha20-Poly1305, the same cipher family as at rest. The vault mints its own ephemeral half per answer, and there is no vault long-term key, so there is also nothing with which an old box could be opened after the fact. The message_log therefore journals a ciphertext, and the plaintext exists only in the RAM of the requesting task.
What the box does not prove is who sealed it. Authenticity here is the topology (the vault answers only params.broker) plus the policy the broker enforced before the delivery. A signature can be added later without breaking the wire form.
Unlock attestation. Before accepting key material the vault verifies its own inbound edges against params.broker plus params.sealed_neighbors. If anything else is wired to it, it stays locked and names the path. The reason: the port boundary applies to mutations, and the birth topology is deliberately exempt (author sovereignty). A code cell has filesystem access, so it can rewrite the tree on disk and let the next boot draw an edge no mutation would have been allowed to add, laundering the gate through a reboot. It still can; it simply never gets the key. An unverifiable neighbourhood fails closed exactly like a wrong one. Since R3 that holds for the empty neighbourhood too: a vault whose broker edge is not wired at all no longer attests, it stays locked (reason broker_unwired, error_code still attestation_failed). If the topology is what vouches for the signature, its absence must not attest.
This is the one place where a cell looks at the topology, and it is a deliberate, narrow exception to “cells know no topology”: read-only, only the edges into its own path, and only ever to refuse.
A woken vault is always locked. The key lives in the task and dies with it. A vault that could resume its unlocked state across a sleep would have to keep the key somewhere that survives the sleep, and no such place exists that is not a worse version of the problem the vault solves.
Auto-unlock at wake (GH #427). A vault inside a sealed hive cannot be reached over the user channel at all: the user channel is by definition a source message (no reply_to), a source message reaches no hive-internal cell, and the only thing that can reach one is an edge, which always carries reply_to and is therefore never the user channel. Since unlock is user-channel-only in the ACL, such a vault stayed locked for its entire life. params.unlock_env names the environment variable holding the passphrase; the cell reads it from the process environment on first use and unlocks itself. Like key_source, the param names a source and never material, so a stolen cell.db on its own stays worthless.
It is off by default. Without the key, the promise of the paragraph above holds unchanged: a woken vault is locked and stays locked until the user channel says otherwise. A sealed deployment declares the exception explicitly.
An unlock lane over edges is explicitly rejected and stays rejected: an unlock message that travels an edge carries the passphrase through the message_log, precisely the failure class the sealed delivery removed.
The self-unlock takes the same path as the user channel: the attestation runs, the passphrase is proven against a stored secret, and an unlock_ttl_ms applies. If the named variable is unset or empty, the refusal is loud and named: an ERROR line in the log and an invalid_input whose message names the variable, instead of a generic vault_locked. status and lock deliberately do not trigger the self-unlock, since neither needs a key and an operator diagnosing a broken unlock_env needs at least one operation that still answers.
A declared but empty unlock_env value is a misconfiguration and is refused at parse time: switching it off means removing the key, and an empty string is nearly always a ${VAR} that resolved to nothing.
Crypto: argon2id from the passphrase against a per-store salt; XChaCha20-Poly1305 per secret with its own 24-byte nonce.
No-delete: a put onto an existing name is a rotation (a new version), and revoke flips a status. Yesterday’s ciphertext stays on disk, which is what makes a revocation auditable instead of a hole. revoke deliberately needs no passphrase, because being locked out must never stop you disabling a credential that leaked.
params:
| key | type | default | meaning |
|---|---|---|---|
broker | string | required | The one sender the vault answers at all. Absolute (/main/access/invoke) or hive-relative (./invoke, resolved against its own path, which is what makes it a template) |
key_source | string | "auto" | auto | prompt | systemd-cred | plainfile. Names a source, never material |
credential_name | string | "vault_key" | file under $CREDENTIALS_DIRECTORY for systemd-cred |
key_file | string | none | required for plainfile. Refused if group or others can read it, the same answer ssh gives for a loose private key |
unlock_env | string | none | GH #427: name of the environment variable holding the passphrase this vault unlocks itself from on first use. Without the key a woken vault stays locked |
unlock_ttl_ms | u64 | none | re-lock after this long |
sealed_neighbors | array | [] | further expected edge neighbours for the attestation |
external_timeout_ms | u64 | 5000 | A-timeout around reading key material (rule 12) |
Storage (its own cell.db): vault_meta (the salt, which is not secret and exists so two vaults with one passphrase do not share a key), vault_secrets (one row per (name, version)) and vault_audit (every operation, refusals included).
Failure classification (error_code, closed): invalid_input (a body that is not inline-readable, no tool_call turn, its text not a JSON object, a missing op, or a missing or empty required argument such as name, grant_id, payload, recipient_key; and a params.unlock_env whose environment variable is unset or empty, where the message names it), unknown_op (an op this cell type does not have, so get is unknown here in exactly the way frobnicate is, and the message names the ops that do exist), access_denied (a caller that is neither the broker nor the user channel, or an op its role may not use; the refusal comes before the operation and lands in the audit trail), vault_locked (an op that needs the key against a locked vault, including the case where unlock_ttl_ms expired mid-call), attestation_failed (the edge attestation at unlock does not match params.broker plus params.sealed_neighbors, or cannot be verified at all; the vault stays locked and names the path), unknown_secret (no secret of that name, or every version of it is revoked) and vault_error (every remaining refusal: a passphrase that does not open this vault, a use action other than sign, a crypto or cell.db error; and a vault whose cell.db was unusable at wake answers every message with it, so it looks locked instead of undefined-broken).
transfer_exempt is deliberately not in that list: it is the substrate’s answer to a transfer body slot (§ Content transfer) and not an emission of the vault, whose handle() never gets to see the slot. The next paragraph says why it must not.
And that storage does not travel (GH #314). A route surface without a get is only half the promise while a seam sits above it that the vault does not answer: the transfer body slot is served by the substrate before handle(), so the two-caller ACL never sees it. And what came out of it needed no passphrase: name, version, status and created_at in vault_secrets are plaintext, the complete inventory with rotations and revocations included, and vault_audit is the complete call history. So the vault declares contract.transfer: "none" (§ Content transfer): its database is exempt from the slot, export as well as import, with error_code: "transfer_exempt". Same construction as the missing operation, a question that has no answer here instead of a rule that can be won.
One limit stands: a determined code cell in the same process can read the vault’s memory while it is unlocked. The designed answer is placement (its own process, its own user), a deployment property that changes no edge. An agent that develops the substrate itself is out of scope by definition; no vault holds against that, and claiming otherwise would be the more dangerous statement.
Emission mode: stateful (lazy), atomic-emitting. One tool_result turn per message.
web, a mounted display substrate
The cell serves under a mount. params.mount names it, the colony’s one listener hands over every connection whose first path segment is that name, and the cell holds its own cell.db. Until 0.22.0 the one surface belonged to the CLI and everything display-shaped competed for that one address, so a colony could not open a second display and a display could not be instantiated by mutation. The type is meant to be instantiated many times: the meclaw-os tree is one hive, the website another, each with its own web cell under its own mount.
Why this does not break the “no ingress cell types” doctrine. The substrate rejected ingress cell types once, and the argument stands: a cell must not implicitly know it hangs on an endpoint. The web type follows the sanctioned exception the proxy cell already is, a long-running cell that owns an external platform connection and mints ingress context at a declared entry edge. The platform here is HTTP-inbound instead of a chat API. Cells still know no topology: a web cell knows its mount and its DB, and nothing else.
Authentication and TLS are external, forever. A reverse proxy (nginx, traefik, caddy) sits in front of the colony’s listener, and this cell type grows no auth story. Separating two displays from each other is the proxy’s job too: they share one browser origin once they sit behind one domain, so cookies, storage and whatever tells two members apart are decided there, and a page that keeps something in browser storage keys it by mount.
port and bind are gone since web@2.0.0, and a document carrying either is refused at parse. A display owns a NAME: params.mount is required, the colony’s one listener hands it every connection whose first path segment is that name, and the page lives at /<mount>/. The refusal names the way out: port: removed in web 2.0.0 — the cell is reached at /<mount>/ on the colony's listener; drop the key and name a mount. Renaming is still a params update, but it takes effect on the cell’s next life, because the name is registered once per life.
A mount change takes effect on the next life. The name is written into the params overlay at once and read when the I/O half next starts, because the listener holds the registration and no message moves a live one. Until then the cell keeps answering under the name it registered, and the cell.db is untouched by the move: objects, components, pages and files are the same rows before and after it.
Dual task, and the I/O half never returns voluntarily. The handler half and the I/O half share nothing: the handler owns the state and the cell.db and is the only writer, and the I/O half owns the axum router and touches neither. They talk over two internal channels, which is exactly what keeps “one task per actor” true for a cell with a whole HTTP surface hanging off it. The A1′ lifetime contract binds hard here: run_io runs for the entire lifetime of the cell and returns only when the cell as a whole tears down. A mount another cell already holds registers nothing: the half reports it and keeps running, because a name collision is an operator’s mistake to read in the journal and not a reason to tear down a cell and possibly a whole colony boot with it. Only the handler closing the internal channels ever ends run_io. There is no server future left that could end under it (GH #592); what can end is the handoff channel, when a respawn of this cell registers over its entry, and the task serving connections then stops taking them and ends with its connections.
A page load costs no cell call. What is served is protocol scaffolding: a csrf meta tag, the container the LiveView client joins on, one small style block, the two script tags, the socket constructor. The picture arrives afterwards, in the join reply. So a colony that is wedged still serves the page, and the client then visibly fails to connect, a state a person can read instead of a blank screen.
“Visibly” is the shell’s own doing, and it is the one style it ships. The vendored client publishes its connection state as classes on the data-phx-main container (phx-loading, phx-error, phx-client-error, phx-server-error), written after a short delay so a blink flashes nothing. Until a page styled them, that publication went nowhere: a page whose socket had gone kept drawing its last picture for as long as the tab stayed open, and a picture drawn a minute ago and a live one are pixel-identical. So the shell’s <head> carries a handful of CSS lines that turn those states into one fixed banner, connection lost, this page may be out of date, on the container the shell writes itself.
This does not retract “the cell type does not decide what a display looks like”. The shell still links no stylesheet and says nothing about the page inside the container (templates/web/README.md, § The Vision token sheet). What it styles is its own element, in the states of the runtime it itself ships, and a runtime that publishes a state with no way to see it is a half-delivered runtime. A page overrides it by declaring the same rules: the block sits in <head>, a page’s rules arrive in the body, and at equal specificity document order decides. templates/colony-view does exactly that, and there is exactly one banner, because ::after is one box per element.
The default does not fade the page behind the banner. opacity on the container would fade the banner with it, and opacity on the container’s children multiplies with a dim the page applies further down, since colony-view dims its own picture by half, and half of a half is unreadable. The banner is the substrate’s signal, and how far a page recedes behind it is that page’s judgement.
The shell knows its prefix. Every request computes a base, the sanitised X-Forwarded-Prefix a proxy sends plus /<mount>, and the shell writes <base>/live, <base>/@client/… and its asset links out of it. A header value that does not match ^/[A-Za-z0-9._~/-]{0,200}$, or one with a trailing slash, is ignored rather than trusted, and so are the two shapes the grammar itself lets through: a leading //, which is a host and not a path, and any .. segment. The LiveView join carries the page URL and the base is stripped off it again, so page.set routes stay names (/, /a/b). The /surface/<cell-path>/ prefix the HTTP API once served is retired (GH #383); the container id and the session token still come from meclaw_surface::session, the one piece of the old machinery that was never about the prefix.
Message contract (tool-call form, bundle-capable): object.create, object.update, object.move, object.delete, query. One tool_call turn is a single op and answers with its metadata on the header; two or more are a bundle and are answered by ONE reply carrying one turn per op in call order, plus a results[] slot with the per-leg metadata (the store’s GH #295 convention). What is counted is tool_call TURNS and not messages: an llm cell emits mixed [tool_call, text] bodies, and prose beside a call must not change how the call is answered.
bundle_errors is stamped unconditionally, 0 included, because a zero says checked and clean, which a consumer must be able to tell apart from nobody stamped it. A bundle is explicitly no transaction: a failed leg does not roll back its siblings. The header’s own error_code keeps its hard meaning (the whole reply is a refusal) and never signals partial failure.
What that means for a bundle that clears and rebuilds (GH #405): an object.delete needs no component, and an object.create looks one up. So an unknown_component refusal structurally removes exactly the constructive legs and lets the destructive ones through, and a patch that wrote nothing still destroyed what was there. The sender’s intent was make the tree look like this; what it achieved was remove what does not belong in a tree I could not build. That covers this one shape rather than partiality in general, and it follows from the same non-promise: the legs run in call order, each on its own, with no stop at the first refusal and no ordering by leg class that the caller could not see in its own bundle.
The recipe for it belongs to the caller, and there is one: a destructive bundle is sent as TWO, the create and update legs first, then the reply’s bundle_errors is read, and the delete legs are sent only if it is zero. That costs one roundtrip and puts the decision where the intent is. A bundle-level switch (atomic, stop_on_error) would be the other shape, and it is deliberately not built, because it is an extension of the public bundle format and does not have to be the answer to this damage.
Components are defined at runtime. component.define {name, template, prop_schema, editable?, layer?} creates one or replaces it. The template is parsed at definition, with the same parser the renderer uses (the one-parser rule), so an unknown {{…}} is answered to whoever wrote it, at the moment they write it. A second, laxer parser here would let a component into the library that the renderer cannot draw. layer is "navigation" or "content". editable names props a browser may write itself, and must name props the prop_schema also declares, since an undeclared editable prop could never be written anyway.
A redefinition re-renders every route. The cell does not track which objects use a component, and a page that quietly kept drawing the old template would be the worse outcome.
page.set {route, root, title?}, and the pages table is the only route source. There is no cell.surface key any more (GH #383): it was removed together with the /surface/* path it declared, and a cell block that still carries it is a hard refusal naming key and file, on every read path (config.md § cell). This retracts the earlier wording that cell.surface “is ignored entirely”, which was true while the key still existed and this cell simply never read it, and is untrue now, because a tree declaring it does not boot at all. Two grammars for one thing was the risk, and one of the two is gone instead of tolerated. It is settled twice over: there is also no code path in this cell that reads it.
The route grammar is one plain segment chain: /, /a or /a/b, with segments of [a-z0-9-]. No @ (that belongs to the cell’s own files under /@client/), no live (that belongs to the transport, since the phoenix client appends exactly /websocket to the socket URL and a page there would shadow it), no trailing slash (or /a and /a/ would be two names for one page). A route is a name and not a URL.
A GET asks both surfaces, and pages win. Whatever is claimed neither by the transport (/live/websocket) nor by the cell’s own bundles (/@client/…) reaches one handler: the materialised page map is asked first, the asset map second, and if both are silent it is the same 404 an unknown page gets, because a display does not enumerate what it does not serve. Why one handler instead of two competing wildcard routes: with two routes the router’s matching order would decide which table a path can reach at all, and a whole table could quietly become unreachable, which an ordered double lookup cannot do. If both tables declare the identical path, the page answers, because the pages table is the only route source and an asset able to take over a declared route would make that sentence false. The Content-Type comes from the row, not from the file name: what a file is, is stated by whoever put it there, and not by a table of suffixes.
Before the first publish a request is 503, never 404 (GH #395). The two halves start together: the I/O half registers its mount and answers as soon as its task runs, while the handler half builds the page map in on_start. The registration does not wait for that publish, so there is a short window, measured at roughly one run in three when the cell is installed into a running colony, in which the display is reachable and has nothing to say about any route. Answering 404 there was wrong in a way that mattered: 404 from this cell is a statement about the pages table (“no such route”), and before the first publish there is no page map to make a statement from, so two different facts arrived as one status code. Nothing on the wire told them apart, and the reverse proxy this cell is deployed behind could not either, so a health check that fired early marked a healthy display broken. The window now answers 503 with the body starting, and a route that genuinely does not exist goes back to 404 the moment the first snapshot is published. Note what the readiness signal is not: an empty page map, because a display with zero pages is a legitimate state that must go on answering 404. If the initial render fails, the display stays 503: it has nothing to serve and will not until a write lands, and 503 is the truthful answer to that, where a permanent 404 would report an empty display as a working one. Who causes that first write is a question with an answer since GH #553: a screen written by an app such as colony-view gets its first picture out of the mutation door’s boot receipt (ruling O-0904-2), where before it the first picture hung on a one-minute tick and an instance with neither tick nor mutation would have stood empty for good.
Assets are pure seed data today. No op writes assets; the map is built from the DB once at start and published, the same construction as the page map, so that a GET for a file costs no database and no cell call either, and it does not change for the life of the cell. The read path is deliberately tolerant: the seed loader stores every JSON string as TEXT, including into a BLOB column, so the cell reads through ValueRef and takes TEXT as readily as BLOB. Changing the schema of a table that has only just shipped would be the more expensive route to the same byte.
One diff per write. Every applied op publishes the re-rendered page and then sends exactly one frame to the viewers of the affected route, and not one at the end of the bundle. Send three writes, see three frames, and a viewer sees each step instead of only the last. The pages are published first and pushed second: a GET arriving between the write and the push already sees the new content, because someone who reloads must never see less than someone who stayed connected.
ord is a sort key and not a list index. An object.move does not renumber siblings, and two siblings may share an ord (the render breaks the tie by id, deterministically). The alternative, shifting everyone else up or down, would mean one caller’s patch silently rewriting rows it never named, and inside a bundle the result would depend on the order the legs happened to run in. A caller who wants a particular arrangement states it, and gaps (10, 20, 30) leave room to insert.
object.delete neither cascades nor re-parents. Either would be this cell guessing what a caller meant about content it cannot see. Deleting leaf-first is unambiguous, and the refusal names the children that block it.
An undeclared prop is refused, not stored. A template can only render what it names, so an undeclared prop is invisible, and accepting it silently would let a model believe it had set something. The refusal names the prop and lists what the component declares.
There are two classes of browser event, and which class an event belongs to is decided by the component’s declaration and not by the event’s name.
Local: an object:set {id, prop, value} on a prop the component declared editable is performed by the cell itself as CRUD on its own DB, followed by a diff to all joined viewers, the sender included. There is zero topology round trip, and no message is created. A drag on a node must not be a conversation with the router. A prop that is not declared editable is refused with phx_reply {status:"error", response:{reason:"not_editable"}} and nothing is written, because the declaration is the authorisation: a browser may move what a component said may be moved, and nothing else.
Semantic: every other event (a button, a form, later a microphone frame) leaves the cell as an ordinary source emission on hop.route = "event", the same shape the proxy cell uses for an inbound platform turn. The header carries event_name, session_id and page_route. The session_id is the nonce half of the page’s LiveView token, and so is unique per page load.
A proxy identity travels the same way. With params.identity_header set, the value of that request header is read at the socket upgrade and rides every semantic event of that connection as hop.user_id; the ingress edge promotes it. An empty param stamps nothing, because a header a client can set without a proxy in front is not an identity.
Promoting it into context.session_id is the ingress edge’s job (set_context) and not the cell’s: a cell states what it knows, and an edge decides what that means for the graph. That is the proxy precedent, and it is what keeps this cell ignorant of the topology it hangs in. The emission’s target is the cell’s own path, the out-edges decide where it goes, and a display whose events nobody listens for dead-letters visibly instead of vanishing.
error_code strings (closed): invalid_input (an unreadable body, a missing or mistyped argument, an undeclared prop, a delete with children), unknown_op (an op this type does not have, and the message lists the ones it does), unknown_object (no object with that id), unknown_component (no component of that name).
Storage (its own cell.db, fixed schema): objects (the object tree: id, parent, component, ord, props as JSON), components (the component library: name, template, prop_schema, editable, layer), pages (route to root object, plus a title) and assets (files the cell serves under its mount). Plus an index on objects(parent, ord), because every render walks a node’s children in order.
The object tree is the single source of what is displayed, and components is the vocabulary it is written in. Components are data and not code: a model can define a new one at runtime by message, and the base set ships as seed rows.
Unlike the store, this schema is fixed instead of declared per instance in params. A store is a typed box whose type its owner chooses; a display’s tables are its contract with the renderer, and a cell that let an instance redefine them could not render a page it had not been configured for.
Seed (seed/<table>.jsonl): the same convention as the store, one file per table, where line 1 is the header {"schema": {…}} and must cover every column, the remaining lines are data rows, and a missing file is a silent skip. It is loaded only on OpenStatus::Created, since a display that re-seeded on every wake would resurrect objects an operator had deleted. Two differences follow from the fixed schema: the set of legal file names is closed (a seed/widgets.jsonl is a typo to report and not a table to create), and the header is checked against the real columns, so a seed written for an older schema fails loudly instead of writing into columns that moved. Both are checked in the plan phase (--validate) and not on first boot. The seed is always loaded by the cell itself, never by the mutation staging seeder: the schema is fixed here and a header line cannot describe it, so staging keeps out of this cell.db (CellFactory::owns_schema, GH #398, before which it pre-built the four tables without keys, defaults and index, and page.set was impossible for every display instantiated by mutation).
Template syntax (closed, a public contract surface): a component template knows four forms and no fifth.
| Form | Meaning |
|---|---|
{{prop}} | the prop’s value, HTML-escaped |
{{&prop}} | the value raw, only for a prop the component’s prop_schema types as "html" |
{{children}} | the object’s children, in ord order |
{{#if prop}}…{{/if}} | the enclosed text, if the prop is present, non-empty and not false |
Closing the list matters. Components are data, and a model can define one at runtime, so a template language that grew by accident would be one a model discovered by accident, and every accidental form becomes a compatibility obligation the moment something renders with it. Anything else between braces is therefore refused at definition time (component.define) and not at render time, because a refusal while rendering would reach a person as a blank area on a page instead of as an answer to whoever wrote the component.
Escaping is the default, and the exception is declared. Props are written by models and by browsers (editable), so they are untrusted for this purpose. {{&prop}} on a prop whose schema does not say "html" silently escapes, because emitting markup that no schema ever promised was markup is the worse of the two failures.
A route is rendered once and the result kept, and a GET answers from it without touching the database and without a cell call. The result is already in LiveView’s packed form, statics plus one slot per direct child of the page root, so a GET does no diff work either. Diffs exist only as a consequence of writes. The shape is the static/dynamic one: n slots carry n+1 statics, the text before the marker, n−1 empty separators, and the text after it. A root with no children (no {{children}}, or a marker with nothing to show) is exactly one static and no dynamic. That slot granularity is a deliberate v1 choice: a patch to any descendant re-renders the slot of its root-child ancestor and pushes only that. Finer would mean one slot per object and a much larger static table; coarser would mean the whole page on every keystroke.
The object tree is depth-bounded while rendering (64). Nothing stops a patch from making an object its own ancestor, and a renderer that recursed into that would take the cell down with a stack overflow, a crash instead of a diagnosis. The bound is reported instead, with the object named.
params:
| Key | Type | Default | Meaning |
|---|---|---|---|
mount | string | required | The name this instance holds on the colony’s listener; the display is at /<mount>/. [a-z0-9-]{1,64}, and the segments the API owns (colony, messages, health, ui, live, @client) are refused. Two instances need two mounts; the second to ask for a name another cell holds registers nothing and logs it. Changes at runtime, read on the next life |
identity_header | string | "" | The request header whose value rides every semantic browser event as hop.user_id. Empty stamps nothing |
external_timeout_ms | u64 | 5000 | Operation timeout (rule 12) around I/O this cell itself initiates |
port and bind left with 2.0.0, and a document that still carries either is refused at validation with the migration in the message: port: removed in web 2.0.0 — the cell is reached at /<mount>/ on the colony's listener; drop the key and name a mount.
Emission mode: long-running (dual task), and not lazy. A display must be up when the colony is, since a web cell that waited for its first message would answer a browser with a blank page until something else happened to talk to it.
voice, real-time speech as a channel
A channel bridge built like web, with audio instead of a page. The cell is long-running, holds two tasks, serves under the mount named in its own params and holds its own cell.db. One WebSocket connection is one client: a browser, an app, a telephony fork. What comes in over the socket is speech and what leaves the cell towards the topology is text turns; what comes back from the topology is an assistant’s text, and what leaves the cell towards the client is speech. One instance per member is the shape here: two members, two voice cells, two mounts.
Audio terminates in the I/O half, and no sample ever enters a mailbox. A message in this substrate is a JSON body that is logged, routed and possibly persisted, and a stream of 16-bit samples is none of those things: it is a wire that has to keep up with a person speaking. So the audio never becomes a message. The connection task holds the client socket and the provider socket and carries the bytes between them, and only the semantics, a partial transcript, a turn, an error, travel inwards as an event and outwards as an emission.
Promoting hop.session_id into context.session_id is the edge’s job and not the cell’s.
Authentication and TLS are external, forever. The same ruling as web, and here it has a second edge: a browser only hands out a microphone in a secure context, so a page served from anything but localhost needs TLS in front of it in any case. A reverse proxy in front of the colony’s listener is what makes a voice cell reachable from a LAN.
Dual task, and the I/O half never returns voluntarily. The handler half owns the session state, the turn machine and the cell.db, and it is the only emitter. The I/O half owns the router it serves on a handed stream, one connection task per client and the provider sockets, and touches neither the state nor the DB. They talk over two internal channels: events inwards (Connected, Disconnected, a control frame, an STT event, the outcome of a synthesis, a bad audio frame), reconfigurations outwards (a frame for one session’s client, start a synthesis, cancel one, close a connection). The A1′ lifetime contract binds exactly as it does for web: a mount another cell already holds registers nothing, is reported to the handler and leaves the half running. An upgraded socket ends with the cell (GH #660): a WebSocket axum handed to on_upgrade runs on a task of its own, so the half watches a channel that closes when it does and every connection closes itself with 1001 — the same code the ordinary end of a connection uses, so a client reads one story either way. The next life registers the mount again and answers a new socket.
Backpressure holds towards the topology, and the socket side counts instead. Inbound the event channel is bounded (64): a handler that falls behind stalls the connection task that is trying to hand it an event, that task stops reading its socket, and TCP does the rest at the sender, so a slow consumer downstream shows up as a slow speaker instead of as a hole in a transcript. Outbound the cell never waits on a client. Every frame for a connection goes through that connection’s own bounded dispatch queue (DISPATCH_QUEUE, 64), and a client that has not drained it by the time it is full loses its session. What decides at the queue is the count and not a clock. The commands still queued are reported, a synthesis the handler is still owed ends as a failed speak_end, and the drop itself is emitted on the error lane with error_code: "client_too_slow". The delivery deadline (external_timeout_ms, below) is the other way out, for the one command already handed on, and which of the two reports first is not promised. What is promised is exactly one speak_end for every Speak the handler issued, whichever way the connection ended.
A session is a connection, and it is not persisted. The client picks the identity: ws://<listener>/<mount>/ws?session=<id>&mode=<auto|hold>, both query parameters optional. Reconnecting with the same session takes the address over; a second connection claiming a live session displaces the first with close code 4409, because two sockets answering to one name is an ambiguity nobody downstream could resolve. Without session the cell mints a uuid7. The identity is opaque, at most 128 characters from [A-Za-z0-9._:-] (anything else is a 400), and ?session_token= is accepted as an alias for ?session=, so a telephony edge passes its own call UUID there. It is what every emission of that connection carries as hop.session_id and, since 1.4.0, as hop.call_id beside it, what the binding edge promotes to context.call_id, and what an in_speak message selects its target connection by. One value, two names: session_id has a second owner one level up, since a member’s session-keeper mints and stamps context.session_id for its own generation on every turn that passes it, so an answer that could only be addressed by that key reached the wrong connection or none (GH #603 § 3, GH #620). call_id only ever means the connection. context.session_id is still read where call_id is absent, so a colony wired before 1.4.0 keeps working unchanged; where both are present the call wins. call_id is deliberately not a member of contract.ingress.context, whose list is the standard header convention and is closed: it reaches context through the channel’s own ingress edge, which is where a key that is not a standard header belongs. Nothing about a session is written down: a connection does not survive a respawn, and a cell.db that claimed otherwise would be lying about a socket that is gone. What the cell.db does carry is the params overlay, and nothing else.
A second door, since 1.5.0: a topic on a web cell’s socket, handed to this cell’s I/O half. A page that joins voice:<call> on the socket it already holds reaches the cell mounted under the name the join names (params.mount, and mount is what makes the mount table entry), and the frames travel as channel events rather than as messages – audio never becomes a message, whichever door it came through. Admission, hello, both modes, 4409 and client_too_slow are the same code and the same sentences on both doors; what differs is only what carries the bytes. docs/voice-wire-protocol.en.md has the events and the join payload.
The audio wire: the cell never resamples. Both directions carry raw PCM16 little-endian mono. Both formats stand in the hello frame, and the client adapts to them. Resampling in the cell would be a lossy conversion nobody asked for, applied in the one place that cannot hear the result, and the client knows its own hardware and does it better. Compression is a matter for the transport in front and not for this type.
The rate is negotiated per connection (GH #619). Without ?sample_rate= the providers’ declarations stand – the inbound rate the configured STT provider demands, the outbound rate the configured TTS provider was asked for. With the parameter the client says what it sends, and the two directions are answered separately: inbound is binding (a rate the recogniser does not serve refuses the connection with 400, before the upgrade, and the answer names the rates it does serve), outbound is a wish (a rate the synthesis provider cannot do leaves its own standing, and hello.audio_out says so). One connection may therefore run at two rates. GET /<mount>/info names both sets as audio_in_rates and audio_out_rates. The reason is telephony: a call is 8 kHz, and upsampling it to 16 kHz before the recogniser doubles the bytes and adds no bandwidth. Deepgram Flux takes 8000 natively, Cartesia and ElevenLabs serve 8000, and the OpenAI transcription provider stays at its 24000 and declares that in hello. encoding stays pcm_s16le in both directions in this version: the telephony edge (mod_audio_stream) streams L16 and nothing else, and ?encoding= with another name is a 400.
A binary frame of odd length is dropped, never rounded. PCM16 mono means every sample is two bytes, so an odd length is not audio at all: it is a framing bug at the sender, most often a client that split a buffer without meaning to. The frame is discarded, the connection stays open, the client gets error{code: bad_audio_frame, bad_frames: n} and the same bad_audio_frame appears on the error lane with hop.bad_frames = n, the count of broken frames on this connection, so a client that keeps losing bytes is visible in a log. The cell sets no threshold and closes nothing by itself. Dropping the trailing byte would make every subsequent sample noise, and doing it silently would leave a person listening to a fault nothing reported.
Outbound framing: what a provider produced is not what a client is sent (audio_out_frame_ms, default 20). A synthesis provider chooses its own chunk size, and Cartesia’s are large. The cell cuts every chunk into frames of at most this many milliseconds before it writes them out, 960 bytes at 24 kHz PCM16 mono. It is an upper bound instead of a fixed size: a frame is either exactly that long or the remainder of a provider chunk, never longer, and an even remainder leaves at once instead of waiting for the next chunk. The cut never runs through a sample: a part-sample tail is held back and travels with the next chunk, and what is still held when the synthesis ends leaves as one short last frame. Nothing is paced and nothing sleeps; the bytes and their order are exactly what the provider produced, only in smaller pieces. Why it is not the provider’s choice: a phone edge is the strictest reader of this wire, and FreeSWITCH’s mod_audio_stream 1.0.3 aborts the whole call (SIGABRT, free(): corrupted unsorted chunks, in its closed-source playback half) on any binary frame carrying more than about 100 ms. Measured: 960 B, 4410 B and 4800 B play; 9600 B, 14400 B and 19200 B kill it. 20 ms is what every RTP stack on that path already works in. 0 is the passthrough: each chunk goes out exactly as it came, which is what a client with no frame-size limit of its own may prefer. The value is declared in hello and on GET /<mount>/info, so a client reads it instead of measuring it. A cancel discards the held tail with the rest of the synthesis, and the echo provider is never framed at all, since byte-identical is its whole point.
Two modes, and both end in exactly one turn. In auto the provider decides where a turn ends: an interim transcript becomes a partial, and an end-of-turn becomes exactly one turn. A provider that withdraws its decision afterwards (Deepgram Flux calls it TurnResumed) does not un-emit, because what was said was said, and the continuation becomes the next turn; the default eot_threshold of 0.7 makes that rare instead of impossible. In hold the client draws the boundary: outside an open hold every provider event is discarded, inside one the end-of-turn events only accumulate in a buffer, and exactly one turn leaves on release. Since voice@2.0.1 the recognition session lives per hold: it opens with the first hold rather than with the connection, and a session that ends while no key is held is not a failure — no error, no 1011, and the next hold opens a new one (GH #657). release does not cut the turn; it says that no NEW audio belongs to it. A recognition provider reports the end of a turn some hundreds of milliseconds after the audio carrying its last words was sent (Deepgram Flux takes 400 to 700 ms), so a boundary that ended on the frame lost the end of every take, and the late end-of-turn then landed in whichever boundary was open next, which is how the previous take turned up at the front of the following one. So a released boundary drains: it stays open for the provider’s answer to the audio it already has, partials keep arriving and belong to the turn that is closing, and it ends at whichever comes first, the provider’s own end-of-turn or the cap release_grace_ms (default 1500 ms; 0 cuts on the frame, the behaviour before this existed). The value is declared in hello and by GET /<mount>/info, so a client reads the upper bound it is waiting on instead of guessing it. Events after the cut belong to no boundary and are discarded, so the next take starts empty, and a hold pressed while one is still draining ends that one at once, with what it has, and opens an empty new one. Whenever a boundary is closed by something other than the provider (the key again, the cap, a mode switch), the session remembers a provider end it is still owed, because the provider is still inside the turn the old audio started and its next end-of-turn carries the take that has just closed. A debt only exists while the provider is actually inside a take: one that delivered its end of turn before the key came up owes nothing, and the cap cuts without a debt — otherwise it would eat the end of the next take (voice@2.0.2). An end of turn with no transcript is a boundary and not an erasure: the interim heard up to that point moves into the buffer instead of being dropped. That one event pays the debt and is thrown away, whether it lands inside the next boundary or outside every boundary; the debt is written off if the recognition session dies first, since a provider that is gone owes nothing and a debt carried over a reconnect would eat the first real end-of-turn of the next take. A mode frame mid-drain closes the boundary the same way instead of being refused: a boundary that has been released cannot be released again, so a refusal would be a dead end until the grace ran out. Pressing the key is itself a barge-in: a hold cancels a running synthesis and clears the queue behind it, because somebody who starts speaking has stopped listening. A turn with nothing in it emits no lane message, in both modes, and answers the client with an empty turn.
What the cell emits: all four are source emissions to the cell’s own path, and the out-edges decide where they go.
| Lane | Body | Header |
|---|---|---|
partial | messages: [{origin: "user", type: "text", text}] | route, session_id, call_id, platform: "voice", eager, mode |
turn | the same shape | route, session_id, call_id, turn_id ("<session_id>#<n>"), platform, mode |
speak_end | messages: [] | route: "speak_end", session_id, call_id, speak_id, reason (done | cancelled | failed), platform |
error | messages: [], meta.detail | route: "error", error_code, msg_type: "voice_error", session_id and call_id when there is a session |
A partial is a draft, and the assistant must never read one. partial exists for a screen and for an app that wants to show speech arriving; it is no turn and carries no turn_id, exactly as a turn carries no eager. The lane ships off (emit_partials: false): whoever listens orders it, so an app declares the partial lane and the manifest that instantiates the channel sets emit_partials: true in override_params, in the same breath as the edge that drains it. Where nothing listens, nothing is emitted and nothing dead-letters, and the client’s own mirror frame is untouched either way.
Speech text: what an assistant wrote is not what a provider should read (speak_plain, default true). An assistant writes for a screen without being asked to (**emphasis**, # headings, - lists, [links](https://example.com), code fences, table pipes), and a synthesis provider reads what it is handed, character by character. The first live call of this cell had Cartesia pronouncing the stars around a bolded word. So the handler rewrites the assistant turn into speech text before it enters the queue, since the queue holds the text that will be synthesised and rewriting after an answer was queued would let a later params flip reach answers that were already accepted. The markup goes and the words stay: a link keeps its text and an image its alt text, a code fence loses its fence and keeps its code, a heading loses its hashes, a blockquote its >, a list item its marker and its [ ] box, an escape loses its backslash and keeps the character behind it (10\% is ten percent), a table row becomes its cells joined by commas, a separator row disappears, and a line break becomes a sentence end (a full stop unless the line already ends in ., !, ?, :, ; or ,). Two shapes are deliberately kept, because somebody dictated them: a * or _ with whitespace on both sides is an arithmetic operator (3 * 4), and an ordinal at the start of a line loses only its punctuation and never its digit, so 5. September 2026 becomes 5 September 2026, because a date and a list item are indistinguishable there and a lost day is worse than an ungainly list. Nothing else is touched: umlauts, punctuation and digits travel as they are, and HTML entities and tags are somebody else’s escape and are left alone. Prose with no markup in it comes out byte for byte as it went in, and the rewriting is idempotent, since a line is rewritten until it stops changing, because an inline rule can uncover a structural marker (`# install`). An answer with no words left in it is not spoken and not refused: a horizontal rule, an empty emphasis, an assistant turn that arrived empty. Nothing is queued, the cell logs it at debug level and the session stays exactly as it was. A refusal would have an agent retry a turn that was fine, and a synthesis of nothing is a speak_start/speak_end pair around silence. speak_plain: false hands the text to the provider exactly as it arrived.
speak_end says when a sentence is over, and it ships off (emit_speak_end: false). Exactly one emission per in_speak the cell accepted, whatever ended the synthesis: done (the last chunk went out), cancelled (a cancel, a barge-in or a hold) or failed (the provider gave up). It carries no words, because whoever waits for the sentence to finish already had the sentence, and it leaves whether or not the connection is still held, because a synthesis that ended because the client went away is exactly the case a waiter downstream must not hang on. The lane is off for the partial lane’s reason and is ordered the same way: a manifest that turns it on draws the edge that drains it in the same breath. The case it exists for is telephony (templates/freeswitch/): a hang-up while the assistant is still speaking waits for the speak_end of that call before the leg is killed, and a cancelled or failed one is what makes the switch stop the half sentence it is still playing. mod_audio_stream dispatches only start, stop, pause, resume and send_text and offers no clear, so the command is FreeSWITCH’s own uuid_break <uuid> all (verification at the switch pending, see templates/freeswitch/README.md § Hanging up). The client’s own speak_end frame on the socket is a different path and travels either way.
What the cell consumes is one lane, in_speak: the last assistant turn of messages[], addressed to a connection by context.call_id, or by context.session_id where that key is absent. No other inbound form exists. A body without a readable assistant turn is invalid_body, a message that names neither key is missing_session, and one naming a session with no live connection is unknown_session. Since 1.4.0 both keys are declared optional in the shipped template, which is what makes the first refusal reachable at all: required, the substrate refuses such a message before the cell sees it, and a message naming only the call would have been refused for naming no session.
Speaking is a queue per session, and nothing is spoken ahead of time. An in_speak message is appended to the session’s FIFO queue and synthesised when the previous one is done. Three things drop the running synthesis and the queue behind it: a cancel from the client, a barge-in in auto mode (the provider reports speech starting while the cell is speaking, barge_in: true), and a hold in hold mode. The cell never synthesises a sentence it was not asked to speak on the chance that it will be. The playback buffer belongs to the client, which is the only participant that knows what it has already played.
error_code strings on the error lane (closed): invalid_body (no readable assistant turn), missing_session (neither context.call_id nor context.session_id), unknown_session (no live connection for it), speak_failed (the synthesis failed), stt_failed (the speech-to-text session failed), bad_audio_frame (a binary frame that was not whole sample frames), invalid_input (a refused params update), client_too_slow (the connection did not drain its dispatch queue and was dropped by count).
The per-connection error codes are a different surface. A frame the client got wrong is answered to that client, on its own socket, as error {code, detail} (bad_frame, wrong_mode, not_holding, already_holding, stt_failed, tts_failed) and does not enter the topology. A malformed frame is a fault of the thing holding the microphone, and putting it on a lane would ask an agent to deal with somebody else’s typo. The two lists share two names because the same provider failure is worth telling both parties about.
A client that stops reading is dropped, not waited on for ever. Every command the handler addresses at a session (a frame, a synthesis, a close) is queued for that connection and delivered by a task of the connection’s own. A client whose socket has stopped taking bytes fills that queue; once one command has waited external_timeout_ms for it, or when 64 further commands pile up behind an already full connection queue (128 in flight), the connection is given up on, the session leaves the cell with the same Disconnected a hang-up produces, and a later message naming it is unknown_session. Nothing goes quiet about it: a speak that was queued behind the wedge comes back on the error lane as speak_failed, for as long as the connection still holds the session; once it is given up on because of the burst, the Disconnected stands for the whole backlog and the rest is a log line. The reason is a boundary instead of a policy: the loop that takes handed-over connections has to stay reachable, so that a new client never waits on one that no longer reads (GH #593).
Providers are traits, and there are two implementations of each. SttProvider yields a name, the input format it demands, and one session per connection; TtsProvider yields a name, its output format, and one synthesis per call, cancellable. The cell ships Deepgram Flux and an OpenAI-compatible transcription session for speech-to-text, Cartesia and an OpenAI-compatible endpoint for text-to-speech, plus echo. ElevenLabs followed as the third text-to-speech adapter and cost exactly what the claim below promises, one new file and one match arm. Two implementations instead of one is what proves the seam, since a trait with a single implementation is a shape borrowed from that implementation. A third adapter is one new file and one match arm in the factory, with no change to the cell, the wire or the turn machine.
Every provider carries its own sub-object, its own base_url and its own credential. The credential is supplied only as ${VAR}, is redacted in Debug, in logs and in error messages, and cannot be changed by a runtime update. The base_url exists so a test can point the real adapter at a fake server, because a provider tested through a mock of itself proves that the mock matches the mock.
Model names and thresholds are params with defaults, never constants. A model name in code is a value with a meaning that expires, since providers rename and retire streaming models on their own schedule, and a threshold in code is a measurement somebody made once, on a model that has since moved. Both belong in the declaration, where an operator can change them without a build.
echo is a provider name, and it is the first thing to run. With stt.provider: "echo" a binary frame comes back byte-identical on the same socket, no JSON travels but the hello, audio_out equals audio_in, and tts may be absent; hold, release and cancel answer wrong_mode. It exists to calibrate the wire (microphone, sample rate, framing, playback, the round trip) before anybody blames a model for what a client’s audio path did.
GET /<mount>/info is a reading at the edge of the cell: the hello declaration as JSON, without opening a connection. What formats does this instance want, which providers does it hold: a question an operator, a health check or a page has, and one that should not require becoming a client to answer.
GET /<mount>/ is a built-in browser test page: one static, self-contained page that speaks this protocol, where you hold a key to talk, watch partials and turns arrive, hear the synthesis and press cancel. It links no external script and ships as part of the cell, because a channel whose first proof needs somebody to write a client first is a channel nobody verifies. The microphone needs a secure context: localhost works, a LAN address does not, and a TLS proxy in front closes that gap. The full frame reference is voice-wire-protocol.md.
Two names are reserved and nothing implements them: the frames spoken and tool_call, and the lanes of the same names. A later speech-to-speech composition, a model that hears audio and answers with audio, needs a place to say what it heard itself and what it wants called, and the reservation keeps that place free. No code stands behind either name today, and this paragraph promises no behaviour.
About measurements. The thresholds and model choices in this type were framed by a measuring round in July 2026, with the models of that month, and those numbers are a measuring frame and not a promise: they say how to measure and what order of magnitude to expect, and they were taken on providers and models that have moved since. What holds is the method. The voice smoke harness measures again, on the instance actually running, echo first and then each provider, and it refuses to report a number it cannot tell apart from the previous run’s.
params:
| Key | Type | Default | Meaning |
|---|---|---|---|
mount | string | required (template: "voice") | The mount name on the colony’s one listener: the socket at /<mount>/ws, the declaration at /<mount>/info, the test page at /<mount>/. [a-z0-9-]{1,64}, and the segments the API owns (colony, messages, health, ui, live, @client) are refused. Two instances need two mounts; the second to ask for a name another cell holds registers nothing and logs it. Changes at runtime, read on the next life |
default_mode | auto | hold | "auto" | Mode for a connection that names none in its query |
barge_in | bool | true | Speech starting while the cell speaks cancels the synthesis (auto only) |
emit_partials | bool | false | Emit partial as a lane. Off by default: whoever listens orders it via override_params. The client’s mirror frame is unaffected |
emit_speak_end | bool | false | Emit speak_end as a lane, one per accepted in_speak, with speak_id and reason. Off by default, for the partial lane’s reason; the telephony hive is who orders it. The client’s own frame is unaffected |
external_timeout_ms | u64 | 5000 | Operation timeout (rule 12) around every provider I/O the cell initiates |
provider_idle_timeout_ms | u64 | 30000 | Idle deadline per provider socket. In auto elapsing is a reconnect and not a standstill: one retry, and only the second failure closes with 1011. In hold an elapse with the key up ends the session in silence, and the next hold opens a new one (voice@2.0.1) |
audio_out_frame_ms | u32 | 20 | How much audio one outbound binary frame carries. Every synthesis chunk is cut into frames of at most this length (an upper bound, not a fixed size), never through a sample; 0 sends each chunk unchanged. 0..=1000 |
speak_plain | bool | true | Rewrite the assistant turn into speech text before synthesis: markdown emphasis, headings, list markers, links, code fences and table pipes removed, and a line break becomes a sentence end. Plain prose is unchanged. Changes at runtime, from the next answer on |
release_grace_ms | u64 | 1500 | How long a released hold boundary waits for the provider’s own end-of-turn before it cuts with what it has. 0 cuts on the release frame. Changes at runtime, read at the next release. 0..=10000 |
stt | object | required | provider plus that provider’s own fields |
tts | object | required unless stt.provider is "echo" | provider plus that provider’s own fields |
port and bind left with 2.0.0, and a document that still carries either is refused at validation with the migration in the message: port: removed in voice 2.0.0 — the cell is reached at /<mount>/ on the colony's listener; drop the key and name a mount.
stt.deepgram: api_key (secret), model, language, eot_threshold (0.7), eager_eot_threshold (0.3), eot_timeout_ms (5000), sample_rate (16000), base_url, keyterms ([]). sample_rate is the rate a client that asks for none gets; the negotiable set is 8000, 16000, 24000, 44100, 48000 (the vendor’s own list).
stt.deepgram.keyterms is keyterm prompting: a list of words the recogniser should expect, meaning names, product terms, anything a general model has no reason to know (["Sam", "meclaw"]). Each entry travels as its own repeated keyterm query parameter, which is the shape the service reads a list in, so an entry made of several words stays ONE boosted term instead of two. Case is kept as written (a proper noun capitalised, everything else lowercase), surrounding whitespace is trimmed, empty entries are dropped, and an empty list, the default, leaves the query exactly what it was. Deepgram caps the whole list at 500 tokens per request and refuses anything longer; the cell does not count, so keep it to the 20 to 50 terms the service recommends.
language reaches Flux as language_hint, which is legal on flux-general-multi and on nothing else, since every other model answers 400 INVALID_PARAMETER. The cell therefore sends it only for a model whose name ends in -multi and leaves it off for everything else, a monolingual model and an unknown name alike: losing the bias is recoverable, losing the request is not.
stt.openai: api_key (secret), model, language, turn_detection (server_vad | semantic_vad), base_url; the input format is fixed at 24 kHz.
tts.cartesia: api_key (secret), voice, model, language, sample_rate (24000), emotion?, speed?, base_url. The negotiable set is 8000, 16000, 22050, 24000, 44100, 48000.
tts.openai: api_key (secret), model, voice, base_url; the output format is fixed at 24 kHz.
tts.elevenlabs: api_key (secret), voice, model, sample_rate (24000), stability?, similarity_boost?, base_url. The voice id is a PATH segment of the endpoint on this API instead of a request field, so an unresolved ${…} or a value carrying /, ?, #, &, % or whitespace is refused by name at parse time. sample_rate accepts only the rates the vendor serves as a pcm_* output format (8000, 16000, 22050, 24000, 32000, 44100, 48000), and the two highest of those (44100, 48000) are documented as requiring the vendor’s Pro tier, a plan question refused by them and not by the parser. The negotiable set is the same list. The protocol has no cancel message, so a cancelled synthesis closes the socket.
Both openai providers are any OpenAI-compatible endpoint and not that one vendor: base_url points them at whatever speaks /v1/realtime or /v1/audio/speech, a local inference stack included.
Runtime params updates (the params body slot, persisted in the cell.db, replayed on wake, the web arrangement). Mutable: default_mode, barge_in, emit_partials, emit_speak_end, audio_out_frame_ms (the I/O half runs on the value its life was built with, so a moved one reaches the wire on the next respawn, exactly like the timeouts), speak_plain (in force from the next answer on, because the handler is what rewrites; only its DECLARATION in hello and /<mount>/info waits for the next respawn), release_grace_ms (read at the next release; a boundary already draining finishes on the deadline it was armed with, because the timer is asleep in the other half), mount (written down at once and read on the next life: the I/O half registers the name when it starts, and no message moves a live registration) and both timeouts. Not mutable: stt and tts. They are not listed as known keys, so an update naming one is refused as unknown. They carry the credential and the format identity of the cell, the two things every live connection was built against.
Emission mode: long-running (dual task), and not lazy. A channel that waited for its first message would answer a microphone with a closed socket.