Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Cell type reference

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; a code cell can.
  • script-determined (a special case): the emission mode follows from the script output, per execution. Only code.

Overview

TypeTaskActor?Emission mode
hivescope marker (authority and mutation boundary for a path prefix) and logical transit node in the routing graphno, no actor, no mailbox, no cell.dbnone (transit, no delivery)
storetyped SQLite storage with schema and seedyes, statefulatomic-emitting
llmLLM inference, holds system state and blob cacheyes, statefulatomic-emitting
bashshell execution (one-shot only)statelessatomic-emitting
codeprogrammable body constructor (Python first)yes, stateless (stateless dispatcher), a stateful code with cell.db deferredscript-determined
web_fetchHTTP clientstatelessatomic-emitting
web_searchsearch-provider clientstatelessatomic-emitting
filefilesystem CRUD with security boundarystatelessatomic-emitting
editfile-editing operationsstatelessatomic-emitting
proxya bridge outwards: a chat platform or a peer colony, dual taskyes, long-runningatomic-emitting (user turn per external message)
timerperiodic event emitter, second-accurate, dual taskyes, long-runningatomic-emitting (schedule body)
mcpMCP-provider bridge, dual taskyes, long-runningatomic-emitting
harnessagent harness (Claude Code) as a supervised child processyes, statefullong-running
subcolonychild colony as one cell (opaque composition facade)yes, long-runningatomic-emitting
vaultsealed secret store, no operation returns a secretyes, statefulatomic-emitting
webmounted display substrate (SSR and LiveView diffs)yes, long-runningdual task
voicereal-time speech channel over WebSocket (audio in, turns out; text in, speech out)yes, long-runningdual task
browserone browser per member: contexts as identities, pages as cards, the picture as a topic on a display’s socketyes, long-runningdual 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 by key (otherwise by every column), so the same content yields the same document. key is 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’s write_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>.jsonl itself and, last, the marker <dir>/seed/export_final.json. Without table, every content table goes into one directory; tables names 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. Without table/tables, every <dir>/seed/*.jsonl in 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. key belongs 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: a store declares its tables in params.schema, and that declaration cannot express a PRIMARY 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. key and keys together, 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 as invalid_input before 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: the export projects every column, and the import refuses any part whose declared schema differs from the target by a column the part carries in addition, a column whose storage class changed, or a column the part lacks. The one exception is declared (GH #822): when the part lacks a column for which the target carries a SQL DEFAULT (a store writes it from the {type, default} column form), the row takes that default. The exception stops at provenance, and there the substrate knows the names: audience, audience_set, channel, speaker and origin_round are never filled from a default on import, and a part without one of them is refused as if there were no default. Birth and boot, by contrast, read the template’s own seed, whose default its author decided. A store whose column was created before #822 without a DEFAULT keeps refusing a part without it, even once the template declares a default: SQLite does not retrofit a DEFAULT onto an existing column, so birth, boot and import agree for newly born stores (fail-closed, no leak).

Every export document carries a version field per table: the table version the store records in meta from params.schema_versions (1 when nothing is declared). A seed header names it only from 2 up. The import refuses a part whose version is above the target’s (import_schema_drift): a newer writer is a template change, not an import question. A table’s version rises and never falls: a template that lowers it is refused at boot, the cell starts DEGRADED and meta keeps the higher one.

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, or relative as a directory of the enclosing hive, GH #1234; 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.

CodeWhen
transfer_exemptthe 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_tablethe table does not exist, or exists but is substrate/derived and therefore not transferable (the text says which of the two)
unknown_columna key column does not exist
import_schema_driftthe part’s schema does not match the target table’s columns: an extra column, a changed storage class, a missing one without DEFAULT or a missing provenance column, or the part’s version is above the target’s. The text names the first such column. The audience gate lives here. Nothing was written
write_deniedthe 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_boundsa 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_errorthe 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_malformeda 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_errorSQLite refused (constraint, type). The whole part is rolled back
invalid_inputargs-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 see meclaw-overview.md section “Graph schema”). Colony reads this at filesystem bootstrap and enters the declared cells into the registry and the edges into colony.db. After the first bootstrap, the persisted edge table in colony.db is the truth, and params.graph is 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 an add_edges endpoint 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’s add_edges and 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.graph of 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 a params.graph may 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": true is mandatory, because the object form exists for slots only and a plain port stays the string. unbound is 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 with error_code: "slot_unbound"; resolved_target is the slot address <hive-path>/<slot-name> (see meclaw-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.db and 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:

    1. The dangling endpoint at boot. An edge onto a declared slot is not an unresolvable endpoint, so the boot commits and --validate-strict stays 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.
    2. edge_schema at mutation time. add_edges onto a declared slot commits before it is filled; the same operation onto an undeclared empty address of the same hive stays edge_schema.

    What a slot is not: it is a valid add_edges endpoint, and never a target of remove_nodes or swap_nodes[].match. Both answer match_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.

    park in detail: the queue is FIFO and bounded per slot by colony.json slot_park_max (default 64, see meclaw-overview.md § colony.json). At the bound the newest arrival is dead-lettered as slot_park_overflow and 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 many park slots and fills none therefore holds up to slots × slot_park_max messages for as long as it runs. That is the declared semantics (“hold it”), and slot_park_max is the lever against it.

    The declaration has a limit. unbound governs 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 as unresolved_path exactly 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 to port, then port must have an edge that carries a message with hop hop out 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 like ports, 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 own because sentence 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'] and hop.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, so port can 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_remember must subscribe to reject. Both names are lanes of the hive’s own params.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 whose set_hop.route constantly names the accepts lane. 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 false is the mutation refused. The residue is a subscription that guards its extra keys with has(): that yields a clean false and 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.route values) instead of prose. Full description and enforcement table in config.md § params.contract. In short: a mutation edge onto the hive path whose set_hop.route is constant must name an accepts lane, every accepts lane must have a door inward, and every emits lane must lead back out through the hive path. Otherwise error_code: "hive_contract", pre-destructive. Opt-in like ports, checked with the same apply_edges as required_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), plus speaker and speaker_ref on a peer turn alone (GH #847), 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> }) or rows (a list of such objects, all in one step). Optionally exactly one of two guards, each a where object naming at least one column (GH #1199): if_absent writes the one row only while no row matches, in ONE statement (a row that stands keeps everything booked on it; rows_affected 0); replace removes the matching rows and writes the new ones in ONE transaction (if a write fails, the old rows stay). A caller writes a row that must exist once this way, never as a delete plus an insert: a bundle is no transaction, the row is missing between the two legs, and a failed insert loses it.
  • select: columns (mandatory, an array of column names with at least one entry, the projection) plus optional where, order_by (array of { "col": "<column>", "dir": "asc"|"desc" }, multi-column) and limit (integer >= 1, with no implicit default and no cap, since the runaway guard is query_timeout_ms), plus distinct (bool, default false, GH #68). distinct deduplicates the projection: two rows that agree on every requested column are one answer, and a limit then 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. Under distinct every order_by column has to be projected, otherwise invalid_input, and a JSON path as an order_by key (GH #915) is always invalid_input there, because columns projects whole columns only: 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 projectionless SELECT *: if columns is missing or empty, the cell answers with finish_reason: "error" and error_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 optional where.
  • delete: optional where.
  • create_table: columns as a 2-level map { "<column>": "<type>" } (types text/int/json), and not as schema.
  • search: match (mandatory, FTS5 query syntax) plus columns (mandatory, as in select) plus optional where/order_by/limit. Since 0.2.0 the match text runs through the same stemming tokenizer as the index (see params.fts), so search term and index term are folded the same way. Only on tables with a params.fts declaration, otherwise invalid_input. Every result row additionally carries a rank column (bm25, smaller is better); without order_by, rank is the default ordering.
  • set_alias (0.2.0): alias and canonical (both mandatory, non-empty) plus optional recorded_at and column. Writes into the alias table of table’s params.canonical binding, as an upsert on alias, so calling it again with the same alias is a correction and not a second row. table names the bound table (facts, say), never the alias table. column names the source column of the binding meant, and is mandatory as soon as the table carries more than one binding (otherwise invalid_input), because an alias is a statement about EXACTLY one identity dimension. Without a binding the op is invalid_input. Resolution is one hop and never transitive: whoever writes an alias writes it already resolved. Two optional guards (GH #973): if_absent: true writes only a free alias, a bound one keeps its binding and the result says {alias, column, canonical, conflict} (conflict: true when it holds another identity); resolve: true follows canonical through the alias table to its end first (at most 8 aliases; a chain back onto the alias or a longer one writes nothing and says {alias, column, conflict: true, refused: "alias_cycle" | "alias_chain_too_long"}; the alias in its stored form). Under a normalising binding, alias and canonical are 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. Optional column (0.2.0) narrows it to one dimension; without column every binding of the table runs. rows_affected counts 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 with delete, run canonicalize, 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 optional limit (default 20), min_score (0.0 to 1.0, default 0.5) and max_values (default 500, cap 5000, the comparison set, quadratic in runtime). The result is an array of { left, right, score }, sorted by score descending 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’s rejected table). 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 ordinary set_alias.
  • reject_pair (0.2.0): remembers a judgement’s No, that two candidates are NOT the same identity. Args: left and right (both mandatory, non-empty, not equal) plus optional recorded_at and column (mandatory as soon as the table carries more than one binding). Writes into the binding’s rejected table, 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. Without params.canonical.rejected the op is invalid_input, since there would be nowhere for the refusal to live. Effect: alias_candidates stops proposing the pair. The revert is the ordinary delete on 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 from src to dst. Args: table plus the column roles src/dst (optional kind/weight, all catalog-validated), start (bind value), optional where (full operator set, applied per edge) and columns (additional edge columns in the path rows), guards max_depth (default 2, cap 5) and max_nodes (default 200, cap 5000). Values above the cap are rejected with invalid_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 no order_by (BFS-style expansion, and the order within one depth is not part of the contract); truncated: true makes the max_nodes cutoff visible.
  • similar: similarity ranking over a vector column via the registered hamming() scalar function. Args: table, vector column, query vector (bind), optional where/order_by/limit, columns (which must not contain distance). Every result row carries distance (smaller is better); the default ordering is distance ascending with a rowid tiebreaker. 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), and NULL stays NULL. A length mismatch between two vectors is a loud sql_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, since NULL embeddings (the backfill queue) would otherwise rank first. Known limits: no enforced model equality (the caller filters model_id itself), 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). A where key and order_by[].col may name a JSON path inside a column instead of the column, <column>$<path> (GH #915, e.g. doc$.scope, doc$.due.start, doc$.tags[0]): the path is $ plus 1–8 segments .name or [n] (n 0–9999 without a leading zero), rendered as json_extract("<column>", '<path>'), and compares by JSON type without casting (the number 3 does not match the text "3"); the column goes through the catalog (unknown_column), a path outside the grammar is invalid_input. 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):

  1. SQL level gives a regular tool_result turn with header.error_code ("sql_error" / "unknown_table" / "unknown_column" / "type_mismatch" / "constraint_violation" / "unique_violation" / "store_degraded", GH #915: an insert or update violates a unique index from params.indexes, the header index and a bundle’s results[] entry name it, the text is {"code":"unique_violation","index":"<name>","detail":…}) and no finish_reason: "error". These are the failures the query finds while running: constraint violation, type mismatch, unknown table or column, and store_degraded (GH #1082) when SQLite could not open a file the database needs for this operation (SQLITE_CANTOPEN, for example because the process ran out of file descriptors). The rationale is that the LLM or caller reads the code and decides (retry, schema correction, different operation).
  2. Args level gives an error message WITH finish_reason: "error". Three codes, plus the degraded cell’s store_degraded (paragraph below): "write_denied" (GH #132, the store declares write_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 the tool_call_id so it stays correlatable in a tool loop), "invalid_input" (malformed body, no tool_call turn, text that is not JSON, unknown operation, missing projection, unknown operator key, guard violation, rejected params update) and "query_timeout" (query_timeout_ms interrupted the running query). This class either never reaches the database or is aborted halfway, so there is no result a tool_result could report on. The earlier wording sorted invalid_input and query_timeout into family 1; that was the documentation and never the code (the doc comment in store/cell.rs has always described the split). Pinned in crates/meclaw-cells/tests/store_1/fitness_store.rs. As-built detail: the turn of an invalid_input/query_timeout rejection carries an empty id, so such an answer cannot be correlated by tool_call_id inside a tool loop, only by ordering. write_denied is the exception, decided after the tool_call has been parsed, so the id is known and travels back.

Only internal errors (DB corruption, spawn error) trigger a cell crash and restart. A cell.db that cannot be opened at wake or at restart is not a crash, because wake and restart run inside the colony task (GH #57, GH #63): the cell starts degraded and answers the message with finish_reason: "error", error_code: "store_degraded" and a text naming the open failure. There is no in-memory substitute database; a write would look accepted and be gone at the next wake. After each such answer the cell goes to sleep, the next message wakes it, and the wake tries the open again (GH #1082). Once the cause is gone, the fd pressure for example, the next message is answered from the cell’s own cell.db, without a process restart. Until GH #1082 this code was sql_error, and readers of the message log looked for the fault in the schema. 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 types text / int / json. A column is its type or { "type": "<type>", "default": <value> } (GH #822): a row written before the column existed (seed, staging, import) takes the default, and it is the column’s SQL DEFAULT, ALTER TABLE ADD COLUMN included; schema_versions ({ "<table>": <int>}, default 1) versions a table, and a header with a newer version, a missing column without a default, a changed type or an undeclared column are refused at birth as schema_mismatch naming the column. PK and NOT NULL are deferred and need a separate design pass; indexes, unique ones and ones on JSON paths included, are declared by indexes (GH #915): { "<name>": { "table": "<t>", "on": ["<column>" | "<column>$<path>", …], "unique": false } }, the name through the same syntax gate as a table, 1–8 keys, created after the schema with CREATE [UNIQUE] INDEX IF NOT EXISTS (table and columns through the catalog, the expression from the same function as the query, so a path filter hits the index), immutable, and an index that leaves the declaration stays in place (no-delete). An existing table is grown into the declaration at spawn: every declared column it lacks is added via ALTER TABLE ADD COLUMN (0.2.0), because CREATE TABLE IF NOT EXISTS alone is a no-op on an existing table and an existing cell.db would 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 a config.json written for 0.2.0 keeps parsing unchanged. source is the written column (which stays byte-identical), target the store-owned derived column, aliases the mapping table the store creates itself (alias PRIMARY KEY to canonical, plus recorded_at). Effect: insert and update fill target from the alias table on every write (a caller-supplied target value is dropped, because the column belongs to the store), and at spawn the store creates the alias table and backfills empty target values once (the same catch-up property the FTS index has). source and target must be different text columns of the declared table, and aliases a syntactically valid name that is not in params.schema. Two bindings of one table have to stay independent: the same source, the same target, a target that is another binding’s source, or a shared alias table (across tables as well) are declaration errors. normalize (default false, 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 through alias_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_value as PRIMARY KEY, plus recorded_at). The alias table cannot carry it, because its canonical is NOT NULL and a NULL there would read as “resolves to nothing”. It is created additively at spawn, filled by reject_pair and excluded by alias_candidates; without it the store behaves as it did before the rejected table existed. It is held to the same rules as aliases: syntactically valid, not in params.schema, never shared with another binding. Immutable like schema. 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 recently recorded_at row wins), and an undeclared column is carried over. Without that rebuild set_alias and reject_pair would be SQL errors instead of upserts, because SQLite refuses an ON CONFLICT whose 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 from params.schema, only text/json columns, and no FTS for tables created via create_table (a known limit). Immutable like schema. Existing cell.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, because CREATE TRIGGER IF NOT EXISTS would 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 a params.canonical binding’s source with its target (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 around unicode61, 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 term lieblingseditor, 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) -s when the preceding character is one of b d f g h k l m n r t w (English plural, German genitive; the guard keeps haus, atlas, bonus whole); (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 a store connection (wake, respawn, DbConn re-open); an external tool that opens the cell.db directly cannot read the <t>_fts table, 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 with finish_reason: "error" and error_code: "write_denied", before anything reaches the database. Reads stay free from anywhere, so select, search, traverse, similar and alias_candidates are never bounded. Bounded are insert, update, delete, create_table, set_alias, reject_pair, canonicalize and the β params slot (which persists into cell.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_surface is 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 what handle() does, and it does not reach what the substrate does before handle() (the transfer slot’s import, § Content transfer). That is what the type-neutral sibling contract.write_surface is 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 via DbConn’s InterruptHandle. It demonstrably also interrupts a running recursive CTE (traverse).
  • Optional seed data (convention path seed/<table>.jsonl). The seed takes effect only on OpenStatus::Created of the cell.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 operation seed_rows at /colony/mutations (GH #456, the path for rows that are permissions and keys, with a digest, an access verdict and a mutation_log row). Since #1233 the mutation door sends the rows to a running store as a message on its seed lane (body slot seed_rows carrying {id, table, rows}, sender /colony/mutations only); the store writes them on its own connection and answers with seed_receipt {id, table, inserted, already_present, digest}, the digest being sha256 over the canonical JSON of {rows, table}, and any other sender gets a receipt with error and nothing written. If the store sends no receipt before the deadline (its cell.message_timeout, else message_timeout_default_ms; switched off, the shipped default applies), the door writes a receipt with error_code: "seed_receipt_timeout" into the ledger itself and still answers committed, because the topology is committed by then; a receipt arriving later is dropped. A store that is inactive once the mutation’s topology stands (disconnected, or failed past its restart limit) gets no lane message, because routing would dead-letter it unanswered; the door writes error_code: "seed_target_inactive" at once (#1315). The door writes into cell.db directly only for a store the same mutation is just growing (birth). The shutdown teardown cannot wait for a receipt, so it takes no mutation: like the drain it refuses each one with shutdown_draining before anything is staged. Both cases check against this store’s declaration: only tables from params.schema, only declared columns, and a row already present column for column is not written a second time. write_surface does 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, canonical and indexes (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 in cell.db as {"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 from cell.db still carries text_id, that is a loud error of the call since GH #95 (error_code: "provider_error", meta.error.source: "translate", every affected slot_path named) 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 $replace marker in the body: "$replace": true in a node of the incoming system subtree 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 in meclaw-overview.md § Replace semantics.
  • messages[]: last-received as-is, with no appended turns. Blob refs are already resolved here; since GH #19 the substrate expands messages_id/text_id before handle(), 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.

Idle (GH #1260): an llm cell without cell.idle_timeout_ms goes to sleep only after 30 minutes without a message (1 800 000 ms), not after the colony default idle_timeout_default_ms (60 000), which holds for every other type except code (there the same 30-minute default ends the runner children, § code → runner modes). The reason is the sealed credential: it lives only in RAM, so every wake pays a vault round before the first call (measured about 195 ms, 250 ms cold against 52–56 ms warm), and a conversation with pauses over a minute paid it in almost every turn. An explicit cell.idle_timeout_ms still wins. The type default only lengthens: a colony that sets idle_timeout_default_ms above 30 minutes gives its llm cells the longer colony value too (in effect max(1 800 000, idle_timeout_default_ms)), so they never go to sleep earlier than every other cell. Shorter works only per cell through cell.idle_timeout_ms; there is no colony-wide way back to 60 s.

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_path is the dotted slot path, value the UBF leaf. The semantics are those of an ordinary system.* update (upsert per path). updated_at is carried by the row itself, and 0 is the customary birth value, since the first real system.* update overwrites it anyway. Only the llm factory’s loader stamps every seed row with 0, the seed’s stamp (GH #1236), a row without the column included; an llm cell born before keeps the wall-clock stamp of its birth; the generic JSONL seeder of the mutation stage writes rows verbatim, where a missing column is a NOT NULL failure. A portable seed therefore carries updated_at in 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 into cell.db never passes. Rejected as well: a nested subtree as value (then the nesting belongs in the slot_path), an empty slot_path, a missing header.
  • The seed applies only on OpenStatus::Created of the cell.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.db is created, so a rejected seed leaves behind no empty cell.db that would make the repaired seed look like a “resume” and skip it forever. The same parse path hangs off meclaw --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":     "",
  "credential_grant_id": "grant:openai@member-example/worker",
  "base_url":    null,
  "temperature": 0.7,
  "max_tokens":  0,
  "max_output":  0,
  "length_continuations": 0,
  "input_soft":  0,
  "input_hard":  0,
  "cost_in":     0,
  "cost_cached_in": 0,
  "price_tiers": [],
  "chars_per_token": 0,
  "tokens_per_image": 0,
  "tool_choice_required": true,

  "external_timeout_ms":   110000,
  "attachment_timeout_ms": 5000,
  "credential_wait_ms":    10000,
  "credential_wait_max":   16,
  "credential_late_notice": false,
  "handover_wait_ms":      0,

  "system_order":   ["identity", "facts", "instructions", "tools"],
  "provider_extra": { },

  "system_max_slots":      256,
  "system_writable":       [ ],

  "http_referer": "${OPENROUTER_HTTP_REFERER}",
  "x_title":      "${OPENROUTER_X_TITLE}",

  "reasoning_effort": null,
  "reasoning":        null,
  "reasoning_wire":   "nested",
  "thinking_budget":  null,
  "model_prompt":     null,
  "supported_params": null,
  "base_url_allow":   [ ],
  "requirement":      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), default 110000 (110 s). On Elapsed: a regular error message with finish_reason: "error" and error_code: "timeout".
  • attachment_timeout_ms (concept A, GH #87): the A-timeout around one attachments[] blob read from the store, default 5000 (5 s). Much smaller than external_timeout_ms, because a blob read is a local filesystem read and not a provider round trip. On Elapsed: a regular error message with finish_reason: "error" and error_code: "timeout", whose detail names the attachment id. Without effect for a cell without consumes.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), default 10000 (10 s). Taken from attachment_timeout_ms’s order of magnitude instead of from external_timeout_ms, because it is a round trip inside the colony. On Elapsed every parked message gets its credential_pending receipt. Without effect without credential_grant_id.
  • credential_wait_max (GH #457): how many messages are parked while a credential round is open, default 16. Whatever does not fit is receipted with credential_pending immediately, so the buffer never grows past it. Without effect without credential_grant_id.
  • credential_late_notice (GH #1225): whether the cell reports a box after a message has already been answered with credential_pending since the last box opened, default false. There is one rule: the box releases fewer messages than have waited for a credential since the last box. Every receipt leads there: the deadline ran out (also when a later message asked again afterwards), a message beyond credential_wait_max was answered at once (overflow), an unusable delivery ended the round, or the broker denied it. The box opens all the same, and the credential is in RAM; with true exactly one message goes to the box’s reply target, {"messages": [], "meta": {"provider": …, "credential": {"outcome": "arrived_late", "grant_id": …}}} with no error_code and no value, so the asker can ask its waiting questions now instead of at the next boot. A box before which no message was answered reports nothing. With false the cell stays silent. Only the llm-registry’s translator turns it on; a cell whose edges have no reader for it leaves it off. Without effect without credential_grant_id.
  • handover_wait_ms (concept A, GH #1232): how long a twin waits for its original’s handover, in ms, default 0 = the cell expects none. See “Handover to a successor” below.
  • provider: names the wire protocol the cell speaks, and not the vendor. The list is closed: "openai" (the OpenAI-compatible HTTP API, both dialects), "decisions" (typed questions in, typed answers out, GH #957) and "anthropic" (the native Messages wire, GH #1239). The value is a string and not an enum, and any other value is hard-rejected at parse time (a model_not_found/invalid_input-equivalent configuration error at spawn). The vendor choice is base_url (OpenAI itself, OpenRouter, vLLM, LiteLLM, Ollama, anything speaking the same wire). A further protocol is added when a consumer concretely needs it (maintainer ruling on GH #387). anthropic speaks POST {base_url}/messages (default https://api.anthropic.com/v1): the key travels as x-api-key (from api_key or the sealed credential, redacted like a bearer, settable through no mapped header), plus anthropic-version: 2023-06-01 as a protocol constant. The translate lifts the system prompt to the top level, turns tools into {name, description, input_schema}, folds consecutive tool_call turns into one assistant turn of tool_use blocks and the tool_result turns, with any user text after them, into ONE user message in history order (nothing is reordered, so a text ahead of a tool_result gets a 400 from the provider); empty or whitespace-only texts are dropped, and neighbours of the same role merge; max_tokens is always in the request, provider_extra is the overlay at the root. Image attachments travel as an image block (source: {type: base64, media_type, data}) behind the text of the last user turn in the same user message, never in a peer turn (GH #1241). reasoning_effort becomes output_config.effort, "none" the off the model takes (GH #1300: thinking: {type: between_tools} on Sonnet 5.5, output_config.effort: low on Opus 5.5, Fable 5/5.1 and Mythos 5.1, which cannot switch thinking off, thinking: {type: disabled} on the older models), thinking_budget becomes thinking: {type: enabled, budget_tokens}, a reasoning object travels verbatim as thinking and wins – except a chat-wire object without a type, which is read as what it means ({"enabled": false} as "none", {"effort": …} as that effort; one that is neither, such as {"enabled": true} or {"max_tokens": N}, has no Messages form and is left out, which is adaptive); temperature goes out only with a supported_params list and only when the list names it, the other fields follow the list as on the other wires (hop.dropped, hop.unverified). cache_mode: "implicit" sets cache_control at the root, "breakpoints" marks the end of system and the block right before the youngest user text (a tool_result of the same message included, never a thinking block); cache_ttl_s of 3600 or more asks for the one-hour mark. The cell keeps the thinking/redacted_thinking blocks of a tool answer in its own cell.db (table llm_thinking, the open rounds only; a conversation’s round goes once its next answer ends it, that of other conversations stays) and sends them back unchanged, signature included, ahead of the tool_use blocks of the open round and only there. Such a replay carries thinking.block_binding: {prefix_mismatch_behavior: "drop_block"} (beside adaptive thinking; an absent thinking counts as adaptive, enabled and disabled stay without it) and the header anthropic-beta: thinking-binding-controls-2026-08-01: a block whose prefix (system, tools, the earlier messages) changed since it was signed is dropped by the provider and the call is answered without that reasoning, instead of a 400 that lost the turn (GH #1298); every block the provider dropped is named on the answer as meta.thinking_dropped ({path, reason} per entry of type thinking_dropped in the response’s input_transformations; absent when nothing was dropped) and in a warn line. x_title and http_referer go along as X-Title and HTTP-Referer as on the other lanes. Back: end_turn/stop_sequence → stop, max_tokens/model_context_window_exceeded → length, tool_use → tool_calls, refusal → content_filter; tokens_prompt is input_tokens + cache_creation_input_tokens + cache_read_input_tokens. 529 (overloaded_error) is provider_error with meta.error.kind = "transient", the spend-cap 429 (enforced_spend_limit_reached) stays rate_limit with kind = "quota_exhausted". An anthropic cell takes only auth: "api_key", no wire_dialect, no provider_extra.stream and length_continuations 0 (the Messages wire refuses a prefill); anything else is a spawn reject anthropic_unsupported_param naming the keys.
  • api_key: the static credential of the auth: "api_key" lane, sent as Authorization: Bearer. An empty api_key is no api_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 no Authorization header at all on both dialects (chat_completions and responses), 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 in config.md § “The empty value”. The broker token of the oauth_subscription lane is not affected, since it does not come from params and 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 in api_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 over api_key see “Sealed credential delivery” below.
  • auth: "api_key" (default) or "oauth_subscription". Selects the credential source, not the provider. Exactly one credential per cell: api_key is required for "api_key" and forbidden for "oauth_subscription", and auth_ref the 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 Codex auth.json format. Required for auth: "oauth_subscription", forbidden otherwise. There is deliberately no default: an implicit ~/.codex/auth.json would let a cell rotate the refresh_token of a live interactive session, so sharing a store is a config decision and not a code decision.
  • wire_dialect: "chat_completions" or "responses"; null derives it (api_key gives chat-completions, oauth_subscription gives responses). A separate axis, orthogonal to provider: the Responses API is the same vendor with a different wire shape and not a different provider, so the provider constraint above is untouched. auth: "oauth_subscription" with "chat_completions" is a configuration error, because the subscription backend speaks Responses only. Since 0.46.0 it changes at run time like a model (GH #853): a model package names its dialect, and the credential it is presented with stays the same, so the same rule is checked again on every update.
  • oauth_token_endpoint / oauth_client_id / oauth_originator: overrides for the OAuth refresh defaults and the originator request header. null means 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 the version request header on the subscription lane. null means 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 with 400 and “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_url overrides the provider default, which is useful for local or proxied endpoints like LiteLLM, Ollama and vllm, all over the OpenAI-compatible wire. base_url_allow (GH #853, immutable) is the list of origins (scheme://host[:port], http or https, no userinfo, no path; compared in normal form, so HTTPS://Host.example:443 is https://host.example) a run-time base_url may point at. Without the list base_url is fixed at run time; the start value itself needs no entry, and null at run time is refused (it would silently fall back to the provider default and take the bearer with it). A base_url could move while api_key could not, so before the list a message could send the bearer to any host.
  • system_order: optional order of the system.* 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 persistent system tree. See “System write gate” below.
  • temperature / max_tokens: on the oauth_subscription lane 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 on auth and not on wire_dialect. A caller who does need one on the subscription lane sets it via provider_extra, an overlay that runs after the body inserts and wins.
  • provider_extra: free JSON block for provider-specific knobs (OpenAI seed, for instance). It is an overlay over common params on conflicts. Anthropic’s cache_control is set by the cell itself since GH #890, when cache_mode is breakpoints (on the anthropic wire since GH #1241 also for implicit); other provider-foreign knobs are active only with the respective provider translate.
  • http_referer / x_title: optional provider attribution (OpenRouter HTTP-Referer / X-Title). They are regular params (audit ruling A4, params-uniform): set in config.json, substituted via ${VAR} from .env like any other param, with no code path reading .env directly and no special header mechanics. Unset (null or omitted) means the header is not sent. A code cell that calls a model or embedding endpoint itself (memory-hive/embed, file-space/embed) carries the same two params in the same form and reads them from params, never from a ${VAR} of its own in the script (GH #1315, lock every_code_cell_that_calls_a_provider_carries_the_attribution_as_params). 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_effort is 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. reasoning is the object block taken over verbatim ({"effort": "low", "exclude": true} or a max_tokens budget, say) for everything the shorthand cannot express. If both are set, reasoning wins, being the strictly more expressive form; they are never merged. This holds across origins too: a reasoning object from a model package outranks a reasoning_effort the cell set itself (birth params or overlay). They are regular params like http_referer/x_title (audit ruling A4, params-uniform) and changeable at runtime via the params slot, because a deliberation budget is a knob and not an identity. Unset (null or omitted) means the field is not sent and the request is byte-identical to one without these params. Every shipped llm cell (all but the decisions lane, which refuses the field) declares reasoning_effort with the default "medium" under params and contract.settings (GH #1300), so an instance sets another depth through override_params on the cell’s path, "none" included, and a registry push that names no effort resets it to medium; the lock is crates/meclaw-cells/tests/llm/gh1300_every_llm_cell_deliberates_at_medium.rs. On the Responses lane the topic is already covered by the dialect (reasoning items), so these two params have no effect there. provider_extra is overlaid afterwards and therefore also wins over a reasoning block set this way.
  • reasoning_wire / thinking_budget (GH #854, chat-completions only, like the two above): reasoning_wire is "nested" (default, the request stays byte-identical to one without it) or "top_level", which sends reasoning_effort at the root of the request body, the form an OpenAI-compatible local server reads (vLLM ignores the nested block, and a local reasoning model then thinks at its server default). thinking_budget is a token count: nested it becomes reasoning.max_tokens next to the effort, top level it becomes thinking_token_budget at the root. A reasoning object set verbatim still wins in the nested form, and a key of the same name in provider_extra wins over both, since the overlay runs last. Shipped templates stay nested and set no budget; a local model’s profile is an instance setting or comes with a model package.
  • model_prompt (GH #853): the prompt block of one model, its peculiarities in a few sentences. No fixed size (GH #1085): it is refused only when it alone is larger than the model’s window can take at all (input_hard, else context_window, at the row’s chars_per_token (three without a row); without a stated window the carrier ceiling of 4 MiB), with its size and the bound. The cell puts it FIRST in the system part, before every system.* leaf and separated from them by a blank line, so it stays apart from the model-neutral persona; empty or unset means no block. It is a param and not a system slot, so it travels with the overlay, returns with $reset, and has no second writer on system. Because it changes only when the model changes, and a model change breaks the prompt cache anyway, it costs no extra cache miss.
  • requirement (GH #858, since 0.46.0): what this cell needs from a model, in prose, for the registry: the role, the latency it can afford, how much context it reads, how deep it has to think and what it may cost, in two to four sentences and never a model name. No fixed size (GH #1085; only the 4 MiB carrier ceiling refuses, because the cell never sends it to its own model), immutable, and without effect on a call: the cell never reads it, the params line shows it as length and hash (requirement=len:<n>,sha:<8 hex>[start], the sha256 of the trimmed prose), and in meclaw-os the llm-registry translates it against its catalogue once per change (templates/llm-registry/README.md § Prose in, a model out). Unset means the cell states no need, and the registry resolves it as before.
  • supported_params (GH #993): the sampling fields the model takes, by their name in the request body (temperature, top_p, reasoning, reasoning_effort, thinking_token_budget). Unset (the default) means the cell sends every sampling field it has a value for, and the request is the request of before. A list means the cell sends a sampling field only when the list names it, and the answer names every one it left out in hop.dropped (a list, only when something was left out); a model that answers an unknown field with an error is then called without it instead of failing. Other fields (model, max_tokens, …) are not the list’s business, and a key in provider_extra is never left out, since the overlay runs last. A package key: in meclaw-os a catalogue row of the llm-registry carries it in its package, and the registry pushes it with the model it belongs to.
  • max_tokens / max_output (GH #1037): max_tokens is the cell’s OWN completion cap, 0 (the default) = none. max_output is the model’s listed output limit, a package key the registry pushes from the catalogue row (0 = not stated). The request carries the own cap, never above max_output; without an own cap max_output; without both 32768. The default was 4096 before 0.61.7 – the limit of an earlier model generation, at which a long consolidation was cut. The params line shows a derived budget as max_tokens=auto:<n>.
  • length_continuations (GH #1037, chat-completions only): how often the cell continues an answer that stops on length without a tool call, default 0, at most 8. The cut text goes back as the assistant’s turn with a user turn asking to go on, the parts are joined into ONE answer, and every usage figure is the sum of all calls. Each continuation writes a line on the log target meclaw::llm::length, and so does an answer that stays cut (budget spent, 0 continuations, or a failed continuation call) – it is emitted with finish_reason: "length", so a reader can tell. Never a silent cut.
  • tool_call_limit (GH #1350, chat-completions only): the most tool calls one answer may carry, the budget of the dispatcher that runs them; 0 (default) = no limit and the request of before, byte for byte. Set, the cell asks for the answer as a stream (stream: true, stream_options.include_usage), puts the parts back together into the one body it reads otherwise, and stops reading at the first call over the limit: the round ends there with provider_error, meta.error.kind tool_call_limit, limit and calls, not after the whole answer. Measured: a fast model wrote 686 calls and 32768 output tokens in one round over 129 s, the dispatcher’s budget (16) applied only afterwards, and the next turn waited behind it in the serial cell. A call is an index of the stream; a new, non-empty id at an index whose call already carries another one is a new call (a server that streams every call at index 0 would otherwise merge them all into one and the limit would count too few). The round has cost something, and the error answer says what is known about it in meta.error.usage: provider when a usage block arrived (the one body of a server that ignores stream), stamped as hop.cost, hop.tokens_prompt, hop.tokens_completion; estimate for a cut stream, which has no usage chunk (it comes last). Then the hop carries no cost or token figure of the cut call, never a 0, and meta.error.received_chunks/received_chars (data: chunks received and characters of text, thinking and arguments) are its only measure. A server that answers one JSON body despite stream has its calls counted against the same limit. A length continuation is one more call of the same round: it is streamed under the same limit and ends with the same error; the figures of the calls the round had finished before it are stamped on that error (summed, as for every continuation), and usage stays estimate while the cut call’s own figure is missing. The param has no effect on the Responses and the Messages wire; there the dispatcher’s budget stays the bound, and the cell writes a WARN line on every restore and every update (tool_call_limit=<n> has no effect on the <wire> wire), because a registry push can move it onto such a wire at run time. As shipped, talky/quick sets it to the budget of its dispatcher (lock the_quick_voice_stops_at_its_dispatchers_budget).
  • output_tps (GH #1350): the model’s measured slow output rate in tokens per second, a package key from the catalogue column of the same name (a row without a measurement brings the rate of the slowest measured row); 0 (default) = not stated. The cell reads it on an update only, and only with external_timeout_from_chain: an update that carries output_tps and no external_timeout_ms of its own has the operation timeout derived by the timeout chain (see below, “After a model change”).
  • external_timeout_from_chain (GH #1350, default false): the cell’s external_timeout_ms is an output time of the timeout chain, so a registry push with output_tps derives it anew. Off, a push leaves the timeout to $reset and the start value, as before: a deadline (talky/decide, presenter/decide, display/judge; an answer after it is worth nothing) is no output time. An instance that names its own external_timeout_ms in override_params on a cell that ships the switch on sets external_timeout_from_chain: false beside it. As shipped, only talky/quick sets it (lock only_a_chain_cell_derives_its_timeout_from_a_push).
  • input_soft / input_hard / cost_in / cost_cached_in (R-HK-15/16, 0.61.7): the model’s input bounds in tokens and its prompt prices in cents per million tokens (cost_cached_in = a token read from the provider’s cache, with fractions), package keys the registry pushes from the catalogue row; 0 = not stated. The cell stamps each one above 0 on every answer (hop.input_soft, hop.input_hard, hop.cost_in, hop.cost_cached_in, beside tokens_cached); the curator weighs a cached window against a rebuild with them. input_hard also refuses: a request whose prompt estimate (bytes / chars_per_token, an inline image as tokens_per_image) lies above it is answered invalid_input with meta.error.kind input_over_hard and never reaches the provider; the same holds for a prompt that alone fills context_window (input_over_window). With context_window above 0 the output budget is clamped to the window minus the estimate, so a local server never sees a budget it refuses. Refusals and clamps write a line on the log target meclaw::llm::window.
  • price_tiers (GH #1318): the model’s price steps by prompt length above its base step, a package key from the catalogue column of the same name; [] (the default) = one price for every length. Each step names above_input (the step applies to a prompt of more tokens), cost_in and cost_out in cents per million tokens and optionally cost_cached_in; above_input rises from step to step, or the cell refuses the params. cost_in/cost_cached_in stay the base step. The cell stamps hop.cost_in/hop.cost_cached_in of the step the provider’s count of the (first) call’s prompt falls in; a step without cost_cached_in stamps its input price as the cached one (no discount assumed, no number made up; otherwise the curator’s cost mark would fall away above the step), an unreported prompt the base step.
  • chars_per_token / tokens_per_image (GH #1085): the rates the estimate counts with – bytes of text a token (fractions allowed) and tokens an inline image – package keys from the package column of the catalogue row; 0 = not stated, and the cell counts a cautious 3 bytes and 1024 tokens. A refusal names the rate (meta.error.chars_per_token).
  • tool_choice_required (GH #1260): whether the model’s host takes a forced tool call, a package key from the package column of the catalogue row; default true. false means a tool_scope with require goes out without tool_choice on the chat and the Anthropic Messages wire (the provider chooses, auto), on the Responses wire with its fixed tool_choice: "auto", and the cell writes one WARN line require_degraded, once per package and waking: the mark lives in RAM only, so a woken cell says the line once more, and a push that changes no package key does not reset it. Measured: gpt-oss-120b, pinned to one host, answered tool_choice: "required" with HTTP 404 (the gateway’s routing filter dropped the only endpoint); on auto it called the right tool 20 of 20 times.

The timeout chain of a shipped template (GH #1097). The crate default above is a floor for a hand-written config. Every llm cell the template library ships derives its two numbers from ONE chain instead, out of the llm-registry catalogue row of the model it is born on (the literal model, the default of a ${VAR:-row} token, or else the row the cell names in contract.settings.external_timeout_ms_row):

  • external_timeout_ms = the completion budget the request carries (max_tokens when the cell names one, never above the row’s max_output; else max_output) divided by the row’s output_tps, plus 10 000 ms for the first token, rounded up to the second. output_tps is a catalogue column: the measured slow floor of the row’s output rate in tokens a second (its note names the measurement). A row that states none (0) counts with the slowest row that does, so an unmeasured model is never given more speed than any measured one.
  • cell.message_timeout = external_timeout_ms × (1 + length_continuations) plus the backstop margin (10 s, at least 10 %; the rule a_shipped_llm_backstop_outlasts_its_own_call already enforced), rounded up to the second – so a continued answer gets every one of its calls in full before the backstop.
  • The same backstop rule holds every other cell the chain names (tools/*, the embedders, file-space/derive): the longest legitimate handling – the operation timeout, for a granted cell the worst sealed-ticket wait plus its call (GH #1061) – plus the margin. A run ahead holds its ticket for its whole operation timeout, every retry included, so a granted cell’s ticket wait counts ceil((credential_wait_max + 1) / max_concurrency) operation timeouts, not single attempts (timeout_ms): Every granted cell’s round counts, its own credential_wait_ms or, without one, the default (10 s), because the cell waits for it inside handle() before the ticket (GH #1338, GH #1339): file-space/embed waits up to 60 s for the credential, 2 × 42 s for the ticket and runs 42 s, a backstop of 205 s; memory-hive/embed sets no round of its own and waits 10 s, 2 × 63 s and 63 s, a backstop of 219 s. An embedder’s operation timeout is its own worst case: (retries + 1) × timeout + retries × backoff + 2000 ms of reserve, rounded up to the second.
  • A number that waits for such a cell is derived from its backstop by the same margin: the curator’s REBUILD_STALE_S (stale rebuild claim) from curator/summarizer, the registry’s OPEN_QUESTION_SECONDS (30 s past llm-registry/translate).
  • Two cells are deadline cells, not output cells, and are held to their own chain: presenter/decide (a decision has no output tokens; its external_timeout_ms 3000 is twice the slowest measured decision, 1007 ms, rounded up to the second, and the stage’s budget_ms is that plus 1000 ms for the way back, so no verdict the decider may still deliver is late), and display/judge (8 s, a screen verdict later than that is no longer about the screen the member sees).

The test crates/meclaw-cells/tests/misc_1/gh1097_time_ceilings_follow_one_chain.rs scans every template and holds each number to the chain, so a row that moves, a cell that is born on another row or a number typed by hand fails there and not in a colony. The chain bounds a hung provider call, not a normal one: an answer is never cut by its operation timeout before the model could have written its whole budget.

After a model change (GH #1350). The registry pushes output_tps, the row’s rate, with every package (a row without a measurement brings the rate of the slowest measured row). In a cell with external_timeout_from_chain, an update that carries output_tps and names no external_timeout_ms of its own sets the operation timeout by the same chain over the merged params: the request’s budget (the cell’s max_tokens, never above the pushed max_output) at the pushed rate plus the first token, to the second. So after the change the timeout of the served model applies, not the value from the row the cell was born on. A push does not change the backstop of a running cell, and a timeout the backstop does not clear is refused at run time (guards below); the cell therefore holds a derived timeout under the longest one its backstop still clears by the backstop rule and writes the cap as a WARN with both numbers. A row slower than the born one then gets at most the timeout the backstop carries. Without output_tps, or in a cell without the switch, the push resets the timeout to its start value by $reset, as before, and writes no line.

Runtime param updates (config.md § Access L.20): params are cell content, not topology state. They change per message, not per mutation. A model therefore has two layers (GH #853). The start value comes from config.json params: ${VAR} and ${VAR:-default} are resolved from the environment on every boot, ${ctx.*} once at instantiation. The run-time path is a top-level params body slot (1:1 with the config.json params block), partial, with last-write-wins per key, and in meclaw-os the operator’s way to it is the llm-registry (see templates/llm-registry/README.md):

{ "params": { "model": "gpt-4o-mini", "temperature": 0.4 } }

An overlay beats the start value, visibly and reversibly. {"params": {"$reset": ["model", "base_url"]}} removes those keys from the overlay and the start value holds again, also across a respawn; in one message the reset runs first and the keys set beside it afterwards, and an unknown name is invalid_input. Every update and every restore on wake or respawn writes one stderr line, llm: params <cell-path> model=<m>[overlay] base_url=<u>[start] … (overlay: <keys>), with the source of each package key; api_key never appears, provider_extra only by its keys, model_prompt only as length and hash. A restore also runs the run-time guards over the overlay and names on a warning what they would refuse (an overlay written before 0.46.0 may carry a base_url no list allows); the overlay stays in force until $reset. A params-only message, {"params": {}} included, stays silent on the lane and answers with that line: a reply there would run into the brain’s out-edges as if it were a model answer.

Which message may change what: the package keys, model, base_url, wire_dialect, reasoning_effort, reasoning_wire, reasoning, thinking_budget, max_tokens, temperature, external_timeout_ms, provider_extra, model_prompt, max_output, input_soft, input_hard, cost_in, cost_cached_in, price_tiers, chars_per_token, tokens_per_image, tool_choice_required, provider, credential_ref and output_tps (the constant MODEL_PACKAGE_KEYS), take effect only from a params-only message, that is a body without a messages slot. A model changes by a params-only message from the operator’s edge; a conversation cannot change the model it talks to. A params slot that rides on a turn and names a package key, or resets one, is not applied at all: the stderr line says so and the turn runs on as before. Other keys on a turn (http_referer, say) are applied first as before, so this call already uses them. No boundary carries a params slot in: the peer mount refuses it whatever a lane declares, and web never builds one. POST /messages is the operator’s own edge. config.json diverges from the live state, which is intended, and stays the instantiation snapshot; on wake or respawn the cell replays its cell.db overlay over the birth params. provider_extra is replaced as a whole by an update, never merged key by key.

Who a push is for (GH #862, since 0.47.0). A params slot on a message whose hop.subscriber names another cell is not applied. The cell compares the value with its own address, the path the substrate stamps on its output sink and never a value from the message; any other value, the empty string and a value that is no string included, is another cell. A params-only message is refused loudly (invalid_input, the detail names the address, plus a WARN line on meclaw::llm::params); on a turn the slot is skipped whole and the turn runs on. A message without hop.subscriber is the operator’s (POST /messages, argus, steward) and is applied as before; a system write that carries one (an affinity push) is no params push. The registry addresses every push it sends this way (templates/llm-registry/README.md § The push), so an edge that forwards its road into another composite’s in_model door moves nothing there.

A refused push goes back to its sender (GH #863, since 0.47.0). When the cell refuses a params push addressed to it – hop.subscriber is its own path – the error names the push on two header keys of its own: refused_subscriber, the cell’s own path (the one the substrate stamps on its output, never a value from the message), and refused_model, the model the push named (empty when it named none or one longer than 128 characters). finish_reason stays error and the error_code is the one the refusal always had. Every shipped composite with an in_model door sends such an error out as model_refused instead of onto a conversation’s or a verdict’s edge, and in meclaw-os it travels back to the registry’s in_refused lane, where show names it (templates/llm-registry/README.md § A refused push). An operator’s refusal (no hop.subscriber) and the refusal of a push addressed to another cell keep their shape, byte for byte.

An allowed silence (GH #1320, input header hop.silence_ok). A turn whose hop.silence_ok is the string "1" may stay unanswered: on the Chat and the Messages wire an answer with neither text nor a tool call is then one empty text turn with the answer’s finish_reason, not empty_answer, so the caller sees the silence. A budget spent on thinking stays reasoning_exhausted. Only an edge sets the key, never a model: talky sets it on its edges into ./brain and ./quick for an inner turn (context.inner_source, GH #1305), whose event has no speaking person. Without the key, or with any other value, an empty answer is the error it was (GH #999), byte for byte.

Guards on the run-time path, each an invalid_input with no partial apply: a base_url whose origin is not in base_url_allow (without a list, any change of it), a base_url of null, and an external_timeout_ms the cell’s backstop no longer clears. That last one is the rule the shipped templates are gated on (cell.message_timeout at least 10 s and 10 % above the call, one function in llm::params), now also checked at run time, because the factory hands the cell its message_timeout. Its own start value is always allowed back, without a start base_url the default host of the start provider’s lane counts as the start value; it is compared as an endpoint: the case of scheme and host, a default port and a trailing / do not count (GH #863); the request path is appended to base_url without a double slash. Since GH #1273 the cell also checks a change of protocol, on the merged result rather than on the update’s keys (every push names provider as a value or in $reset): when the effective provider changes, only openai ↔ anthropic is allowed, and decisions as target or source is refused (detail provider_switch_refused); and the effective base_url has to be concrete and either come from this update, where it is checked against base_url_allow, or be the start value under the start provider (a pure $reset), else detail provider_switch_needs_base_url, because without a host of its own the new lane would fall back to its default host and take the credential with it. Off the start provider the merged result also needs a grant (credential_grant_id) and a non-empty credential_ref, else detail provider_switch_needs_credential – the start credential (the env api_key or the grant’s own secret) never goes to another provider’s host; the way back to the start provider stays free. A change of model or provider writes one llm_switch line with from_model, to_model, from_provider, to_provider and credential_ref, names only. When, after the switch to anthropic, an open tool round lacks its signed thinking blocks, that call goes out without thinking (line thinking_skipped_after_switch).

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; since GH #1273 the channel to the vault: which credential travels on it the package says with credential_ref, and whether the grant may spend it the access hive decides, whereas a message able to repoint the channel itself would meet no check at all) and the credential half of the auth dimension, meaning auth, auth_ref, oauth_token_endpoint, oauth_client_id, oauth_originator and oauth_client_version; a model package that needs another channel needs a mutation. provider left the list with GH #1273: the protocol belongs to the registry’s row, and the guard above holds the switch to openai ↔ anthropic with a host of its own. The rationale: auth and auth_ref are credential identity, 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. base_url_allow belongs here for the same reason, and so does requirement (GH #858): it is the template’s statement of what the cell needs, the registry reads it from the template, and a run-time copy would be a second truth nobody translates. wire_dialect left the list in 0.46.0, because it chooses the wire shape and not the credential, and the rule that pairs it with auth is checked on every update. 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. GH #1232 adds handover_wait_ms: a waiting cell takes a handover import, which writes system past the system_writable gate, and a message allowed to arm the wait would reopen that write after the next wake; only the end of the wait writes the key, and only to 0. 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 – except the origin of a refused base_url, which carries no credential and says which endpoint was refused.

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:

  1. The cell emits a request with header.route == "credential_request" and header.grant_id; the body is a tool_call turn carrying {"grant_id": …, "operation": "vault.deliver", "payload": {"recipient_key": <64 hex chars>, "cred_ref"?: …}}. The recipient_key is the public half of an ephemeral X25519 pair the cell mints per request; the private half stays in RAM. cred_ref is there only when the package names a credential_ref (GH #1273); grant_id stays the bare grant. The cell keeps one slot per grant and ref in RAM and matches box and denial by the call id, so a switch back finds its credential without a new round.
  2. 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 (default 16) and the round by credential_wait_ms (default 10000). It lives in RAM only, like the credential itself, and survives no sleep.
  3. 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. When a message has already had its receipt since the last box opened (deadline, overflow, unusable delivery or denial), the box opens the same way; with credential_late_notice the cell reports it once (GH #1225).
  4. 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. One grant serves every provider (GH #1273): when the registry’s row names a credential_ref, the cell asks for it on the same grant, and the access hive delivers only when the grant’s class (scope.cred_class) matches an active catalogue row of that ref, else it answers cred_ref_not_granted and the parked round is answered at once (templates/access/README.md).

Where the grant lives (GH #1329). A template ships no access of its own; its cells obtain their credentials by grant from an access of the colony. A template that places a cell with a credential_grant_id therefore brings no broker of its own: it declares the credential lane at its rim, credential_request in contract.emits and in_sealed in contract.accepts, both with at naming the asking cell, in_sealed usually required (config.md § contract). The consumer that installs the template seeds the grant into an access of the colony (seed_rows; the builder recipe seeds it in the member’s access) and draws the two v-lanes in the same declaration: the ask from the cell to the access, restamped in_invoke with context.requester on the edge, and one answer per grant back, guarded on hop.operation == 'vault.deliver', hop.grant_id and hop.requester. One grant id may serve several consumers of one access, each with its own requester (GH #1332): the broker looks a grant up by (grant_id, requester), checks the expiry and counts the quota on the requester’s own row (a revocation stays per id) and names the requester on its answer, so the guard on both hands each consumer only its own box. A broker refusal stays with the access and its consumer; the cell learns of it only by credential_wait_ms running out. templates/freeswitch/README.md shows the form for a channel, the builder’s grow_level draws it for a line it grows.

Handover to a successor (the handover slot, GH #1232). An llm cell passes its state on by message, on its own connection, instead of the substrate reading its cell.db. It answers {"handover": {"operation": "export"}} with exactly one message to reply_to, delivered directly and never along its edges, because the content is personal; without reply_to the export is an invalid_input. The answer already is the import form, {"handover": {"operation": "import", "parts": […]}}: two documents in the form of the transfer slot (meclaw-cell-export/1), the whole system table and the params overlay (key, value as JSON text, updated_at), plus one row for each key of the reasoning budget the cell runs on that the overlay does not hold (GH #1236: so the whole running budget travels, birth params included). Whoever forwards it to the successor unchanged has handed over. The import writes on the receiver’s connection: the system rows so that the original wins (unlike a transfer import, which the receiver wins): a row of the original replaces the receiver’s row of the same slot when it is newer (GH #1236: a receiver’s row at least as new stays, so a twin with a seed of its own keeps its instructions), so a seed slot the original overwrote at run time does not fall back to its seed value, and a slot only the receiver holds stays; of the overlay only the reasoning budget (reasoning_effort, reasoning, thinking_budget, max_tokens) as a set, and only when the receiver’s start value and overlay name none of these keys. The original’s model package never travels. Only a cell that is waiting for an import takes one (see below); any other is an invalid_input and writes nothing, because it would write system past the system-write gate. When the value of even one system row is no JSON object, the whole import is an invalid_input and writes nothing. An import that is taken is answered with silence.

A twin born with handover_wait_ms above 0 parks every inference message (one with messages) until the handover is imported, the way a cell with a grant waits for its credential; plain system and params writes run at once. The parked messages then run in arrival order. When no handover comes, handover_wait_ms is the A timeout of that wait: the cell wakes itself with {"handover": {"operation": "expire"}} to its own address, writes a WARN line and runs on without the inheritance; the wait is sized for one turn (the bounds at a swap: rewiring). Either way it writes handover_wait_ms: 0 into its overlay, so a wake does not wait again. The parked messages live in RAM only.

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 bounds one leaf; unset (the default since GH #1085) a leaf is refused only when it alone is larger than the model’s window can take at all (input_hard, else context_window, at the row’s chars_per_token (three without a row); without a stated window the 4 MiB carrier ceiling), and the refusal names the size, the bound and its source; system_max_slots (default 256) 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 its handover and merely cannot open new subtrees.
  • Allowlist, opt-in. system_writable is a list of slot-path prefixes (relative to the system subtree: "handover", and not "system.handover") that a message may write. Prefixes match on segment boundaries only ("identity" covers identity and identity.soul, never identityx). 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 @external operator lane) and every topology writer (handover from the summarizer hive, tools.* from MCP discovery, memory/consult from the collector hive, identity/instructions from a persona cell) keep working unchanged. Since GH #264 the allowlist covers the replace root too: a $replace marker 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 under system has 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).

Tool scope per call (tool_scope, GH #845): a top-level body slot {"allow": [..], "deny": [..], "hint": [..], "require": bool}, all four parts optional, narrows the tool menu for exactly the request it rides on. The durable menu under system.tools.* in cell.db stays untouched; the next message without the slot sees the whole menu again. A name matches an entry by its slot key under system.tools or by the function name of the tool object. allow (when set) cuts to the intersection, then deny removes. An entry that hint names and that is still standing travels at the hint level: only with its name, the first sentence of its description and the schema {op: string} (op required); the model gets the ops and their arguments through help. hint neither narrows nor widens, and the entry keeps its place in the menu. The menu’s order is kept, the filter never re-sorts and never takes the order of the allow list: the same scope yields byte-identical tools, which keeps the provider’s prefix cache warm across the turns of a session. A name the menu does not know is ignored (one WARN line per call); when nothing is left, the request goes out without tools, like a brain without a menu. require: true (GH #1260) asks for a tool call on exactly this request: chat wire tool_choice: "required", Anthropic Messages wire tool_choice: {"type": "any"}, Responses wire tool_choice: "required"; it overrides a static provider_extra.tool_choice for this one call. When the package says tool_choice_required: false, the request goes out without the requirement (chat and Anthropic Messages wire without tool_choice, Responses wire with its fixed auto), with one WARN line require_degraded per package and waking. On the Anthropic Messages wire the requirement holds only while thinking is off for this request (no thinking, or thinking: {"type": "disabled"}): beside thinking that is on, the provider refuses a forced call with a 400, so the request goes out without the requirement, with the tool choice as it was built, and a WARN line says so. When no tool is left standing after the scope, the request goes out without the requirement and a WARN line says so (a provider refuses a required call without tools with a 400); decisions refuses every tool_scope, this one included (decisions_unsupported_param). tool_scope: null counts as absent (no scope). A slot that cannot be read (not an object, another key, not a list of strings, a require that is not a boolean) is an invalid_input reject before anything is written, because ignored silently it would hand the model the whole menu. The scope does not travel over a failover edge: the error output passes messages[] on, tool_scope not, and a failover llm attached by topology sees its full menu. The scope is a property of the channel, constant per session; who sets it is the collector’s business. The one exception is require per turn: the simple-agent door reads its turn’s tool_scope.require and stamps context.tool_require "1", and the collector then sets require: true on the turn’s FIRST brain call only, the later calls of the round go without, so the turn can end in text (GH #1292).

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/*. On chat_completions each attachment becomes an image_url content part of the message of the last UBF turn with origin: "user" (GH #847: never of a peer turn, although that one goes out as role user too), whose content turns from a string into a content array (the text first, then the images). The URL is a self-contained data:<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 elapsed attachment_timeout_ms yields error_code: "timeout". Every detail names the attachment id and the reason, and the inbound messages[] 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 #94 responses). On wire_dialect: "responses" each attachment becomes an input_image item in the typed input[]: {"type": "input_image", "image_url": "data:<mime>;base64,<…>"}. On this wire image_url is a string and not an object (pinned reference ContentItem::InputImage, openai/codex @ 266c6920, protocol/src/models.rs:716-734). The items attach to the message of the last user turn in the input[], never to a peer turn; without a user turn they become an appended user message 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 incoming messages[]
  • system.* is not emitted, being private cell state
  • meta (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):

HeaderContent
finish_reason"stop" | "length" | "tool_calls" | "content_filter" | "error", mandatory
tokens_promptinput token count
tokens_completionoutput token count
modelmodel the provider actually used
refused_subscriberonly on the refusal of a params push addressed to this cell (GH #863): this cell’s path
refused_modelbeside it: the model the push named, empty when it named none
droppedonly with supported_params and only when the cell left a sampling field out (GH #993): their names; since GH #999 on an error as well
input_soft, input_hard, cost_in, cost_cached_inonly on a successful answer and only above 0 (R-HK-15/16): the package’s input bounds in tokens and prompt prices in cents per million tokens, for the curator; with price_tiers the prices of the step the prompt falls in (GH #1318)
unverifiedonly without supported_params (GH #999): the sampling fields the cell sent unchecked, in the order of the cell’s field list
error_codeonly on finish_reason == "error": "rate_limit" | "auth" | "timeout" | "model_not_found" | "provider_error" | "invalid_input" (param-update reject, immutable, unknown or malformed key, or a params push addressed to another cell) | "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:

Caseerror_codemeta.error.kindextra
subscription quota spentrate_limitquota_exhaustedresets_at (unix seconds), plan_type
plan does not cover the modelrate_limitplan_not_includednone
ordinary rate limitrate_limitrate_limitednone
token expired, even after refresh and one retryauthauth_expirednone
refresh token permanently deadauthauth_permanentre_login_required: true
token store missing or unreadableauthauth_store_unavailablenone
5xx or overloadprovider_errortransientnone
upstream error inside a 200 body (GH #75)that of the stated statusthat of the stated status, else coarse (rate_limited / unauthorized / model_not_found / provider_error)in_body: true, upstream_status (when stated), upstream_message
the provider refuses a sampling field it names (GH #999)unchanged: model_not_found on a 404, provider_error on a 400 or 422, inside a 200 body that of the stated statusunsupported_paramupstream_status, upstream_message (at most 500 bytes); inside a 200 body also in_body: true
a tool call without id or function.name, or with arguments that are no JSON object string (GH #999; a string whose content is itself the JSON string of an object is unpacked once, GH #1360), or a call delivered as harmony tokens in the text to a name outside the menu or with unreadable arguments (GH #1260)provider_errormalformed_tool_callnone
an answer with neither text nor a tool call (GH #999), unless hop.silence_ok is "1" (GH #1320)provider_errorempty_answernone
the same with finish_reason length and a thinking trace (GH #999)provider_errorreasoning_exhaustednone
text/event-stream on the chat wire (GH #999)provider_errorunexpected_streamnone
an answer with more tool calls than tool_call_limit (GH #1350)provider_errortool_call_limitlimit, calls (streamed: the first call over the limit, so limit + 1), usage (provider/estimate), received_chunks, received_chars
a prompt estimate above input_hard, or one that alone fills context_window (R-HK-15/16)invalid_inputinput_over_hard / input_over_windowinput_estimate, chars_per_token, input_hard or context_window; no provider call

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: one provider per instance; the cell implements the OpenAI, the decisions and the Anthropic Messages translate (provider above). 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_call and tool_result are pure UBF messages[] turn types with id as the pass-through correlation anchor (a value from the provider). Tool schemas are translated by the translate from system.tools.* into the provider-native tool set, which is format translation and not a tool loop.

  • Wire merge of consecutive tool_call turns (request build, ruling 2026-06-11). During UBF-to-request mapping the translate merges consecutive assistant tool_call turns into one provider-native assistant message with tool_calls[]. The OpenAI wire contract requires that an assistant message with tool_calls is immediately followed by tool messages for each tool_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 = one id), and ids stay pass-through. The response return path stays unchanged, with each provider tool_calls[i] becoming its own UBF turn.

  • The role peer (GH #847): a peer turn (the other side’s words, meclaw-overview.md § “messages[] schema”) goes out as role user in both dialects, its text behind a one-line frame and a line break. The frame comes from the turn fields alone, never from the text: text from the other side that itself starts with [peer …] stays text behind the real frame and is not masked. speaker is neutralised (format characters of Unicode category Cf — zero-width characters, bidi controls, the BOM — are removed; line breaks, control and other whitespace characters become a space, [/] and their look-alikes (fullwidth, white, quill, stroke and tick forms) become (/), · and its look-alikes (•, ∙, ⋅, ・ and the like) become -, trimmed, capped at 64 characters), a speaker_ref outside the schema’s form is dropped. The translate never sets a name field. The frame forms:

    turn carriesframe
    speaker_ref and speaker[peer <ref> · <name>]
    speaker only[peer · <name>]
    speaker_ref only[peer <ref>]
    neither[peer]
  • 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:

    paramwire target
    model, temperature, max_tokens, provider_extra (overlay)request-body JSON
    reasoning_effortrequest-body JSON reasoning as {"effort": …}, or reasoning_effort at the root with reasoning_wire: "top_level" (chat-completions only)
    thinking_budgetrequest-body JSON reasoning.max_tokens, or thinking_token_budget at the root with reasoning_wire: "top_level" (chat-completions only)
    model_promptthe first block of the system message (instructions on the Responses lane)
    reasoningrequest-body JSON reasoning, verbatim (chat-completions only; wins over reasoning_effort)
    http_refererHTTP header HTTP-Referer
    x_titleHTTP header X-Title

    The header table is a closed allow-list. Authorization is not a params-controllable header: it is the api_key bearer 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.

Harmony tokens in the text (GH #1260). An upstream serving a gpt-oss model now and then delivers a tool call as raw harmony tokens in content instead of tool_calls (measured: 2 of 200 calls, <|start|>assistant<|channel|>commentary to=functions.handoff <|constrain|>json{"path": "complex"}<|call|>). On the Chat and the Responses wire the cell reads a text with a harmony frame (<|channel|> or <|start|>assistant) as a sequence of harmony messages: one addressed to=functions.<name> becomes a tool_call turn (id harmony-<response_id>-<n>, finish_reason tool_calls), but only for a <name> the sent menu carried; another recipient, a name outside the menu, or arguments that are no JSON object are provider_error with meta.error.kind malformed_tool_call, never text to a person. The analysis channel falls with its content, final, a commentary without a recipient and a message without a channel stay as the answer text, the control tokens themselves fall; with neither a call nor text left it is empty_answer. An echoed tool result block (role functions.<name>, recipient assistant) falls like any non-assistant message. A text without a harmony frame stays byte-identical, <| in it included; so does a text whose frame is no complete pattern (a channel other than analysis/commentary/final, or a message with neither a body nor a closing token) — prose that only names the tokens loses nothing. The continuation of a length answer is read the same way. A name from the model appears in the error detail with at most 64 characters (a safety ceiling, with the full length).

Tool names checked against the menu (GH #1373). Every tool_call the answer returns – on the Chat, the Responses and the Messages wire, and the calls recovered from harmony tokens – is checked against the tool names of the menu the request sent. Every menu entry with an op enum – a folded family (web {op, args}) as well as a hint or a flat op tool (fs {op, path}), recognised by the schema shape, not by the folding – is declared under its name and under the names of its ops as well (web_search = web_ + op, the name the dispatcher gives a family); the op of a call to such an entry is read where the dispatcher reads it – beside args, inside it when the one beside is blank, an args that is a JSON string unpacked once and written back as a string – and checked by the same rule against the enum (measured in a lab run: the quick voice’s folded menu holds only web, so the rule found no entry for web_search"}). A name that is no declared tool but becomes exactly one once trailing JSON punctuation (", }, ], ,) and whitespace are cut off is repaired to that tool, once (an op with a rest to its op, only arguments changes), and the cell names it on a WARN log line tool_name_repaired with the call id (measured: a fast model returned web_search"} for web_search, and the call cost a round against unknown_tool and a second search). Any other undeclared name travels as it came and is answered within the same round by the tool side’s unknown_tool result, which the model reads; a recovered harmony call is read against the names of the menu itself only: to a name outside the menu, an op name F_op included, it stays malformed_tool_call. A request without a menu leaves the answer byte-identical.

Provider conformance (GH #998, GH #999, GH #1000). A provider may refuse a sampling field under strict routing, or take it and ignore it; the cell’s own tests run against a mock and see neither. So the cell checks both sides of a call, and a tool measures each catalogue model against its real endpoint:

  • What the cell sends. A sampling field goes out only when the model’s supported_params names it, and hop.dropped names every one left out, on a success and on an error. Without a list the request is the request of before, and hop.unverified names the sampling fields that went out unchecked (a provider_extra key counts when it is one of them).

  • What the cell takes back. An answer is checked before it becomes a turn, and a refusal says what it was in meta.error.kind. error_code keeps its value, so an edge written for it still matches:

    meta.error.kinderror_codesourcewhen
    unsupported_paramunchanged: model_not_found on a 404, provider_error on a 400 or 422, inside a 200 body that of the stated statuswirethe provider’s refusal names a field it does not take; upstream_status and upstream_message (at most 500 bytes) carry its sentence
    malformed_tool_callprovider_errorparsea tool call without id or function.name, or whose arguments is not a string holding a JSON object ("" reads as {}; one level too many – the string holds the JSON string of an object – is unpacked once and named on the log line args_double_decoded, more levels stay this error, GH #1360); likewise a harmony call in the text whose name the menu did not carry or whose arguments are no JSON object (GH #1260)
    empty_answerprovider_errorparseneither text nor a tool call
    reasoning_exhaustedprovider_errorparsethe same with finish_reason length and a thinking trace: the reasoning used up max_tokens
    tool_call_limitprovider_errorwirethe answer opens more tool calls than tool_call_limit allows; streamed, the read ends at the first call over it and the rest of the answer is never read (GH #1350)
    unexpected_streamprovider_errorwiretext/event-stream on the chat wire, which reads one JSON body; provider_extra.stream: true on that wire is refused at boot and on update
  • How a model is checked. workshop/tools/llm-conformance/conformance.py (a maintainer tool; it does not ship with the exported tree) sends small probes and the cell’s own request for a catalogue row to the endpoint, against a hosted gateway or an OpenAI-compatible local server:

    checkwhat it proves
    K1, K2a minimal chat answers as one JSON body of the expected shape, with token counts
    K3each sampling field, one request each: taken, refused or accepted_unverified. Under strict routing a gateway refuses only fields it knows, so taken needs a strict 200 and the field in the model listing; a local server is never strict, so nothing there is taken
    K4the listing and K3 agree (n/a on a local server)
    K5, K6a tool call asked for in the turn comes back as a tool call; a reasoning answer is text, or is named as exhausted
    K7no stream on the chat wire
    K8the cell’s request for the row (crates/meclaw-cells/tests/fixtures/conformance/requests/<slug>.json, built from the catalogue by the cell itself) is taken as is
    K9--record <dir> writes measured.json and the raw answers

    A cost guard stops a run before a call whose worst case would pass --budget-usd (0.05 by default). The catalogue lists come from this measurement: a row’s supported_params is its taken set, its record lies under crates/meclaw-cells/tests/fixtures/conformance/<slug>/, and h5_the_catalogue_matches_the_measurement keeps both equal offline. A changed list therefore means a new measurement, not an edit.

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 the output was cut, GH #83/#1085, see below).

params: typically the command to execute or the script-path convention. max_bytes (explicit byte cap on the returned output, GH #83; unset by default since GH #1085, see below). Optional sandbox (S4/GH #35, completed in GH #85; schema in config.md § params), the process sandbox block for the spawned shell. Optional cwd (GH #1234), the directory every command starts in: absolute (must exist), or relative as a directory of the enclosing hive that the spawn creates there, the same anchor as base_path for file and edit; a relative value that leaves the hive (.., ., empty) is a loud boot error. Without cwd the shell starts in the colony’s process directory. cwd is a working directory, not a boundary: the shell may still change it, the boundary is sandbox.

Size of the result (max_bytes, GH #83, GH #1085). A tool result re-enters the prompt on every later round of a tool loop, so its size belongs to the window of the model that reads it, not to a fixed number. The combined text (stdout plus the stderr sentinel block, if any) is cut on a UTF-8 boundary to max_bytes when the operator sets it; otherwise to the tool-result share of the window the call names (input_soft in the message’s context or hop, 10 % of it at three bytes a token; a curator’s context.input_soft reaches the tool on any path, while the hop stamp the llm cell puts on every answer, a tool-call bundle included, lives for one emission: it reaches a tool directly behind the llm cell, or behind a dispatcher that hands it on to each call, as dispatcher@1.6.2 does); with no window named it goes back whole. A cut is visible, never silent: text ends in ...[cut: <k> of <n> bytes shown; <hint>], header.truncated: true, and header.bytes reports the full size before the cut. A cut may clip the stderr block, and the mark and header.had_stderr stay the reliable signals. An output over the carrier ceiling (4 MiB, the largest body a colony mount takes) with no window to cut it to is refused whole: error_code: "too_long", the text names the size and the ceiling.

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 != 0 is a NORMAL tool_result: exit_code is always in the header, 0 included. 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 invalid command field).
  • exit_code = -1 on signal-killed or abnormal termination, a platform-unspecific convention. On a timeout there is additionally error_code: "timeout".
  • stderr sentinel format, inserted only when stderr is non-empty:
    <stdout>
    
    ##meclaw-stderr-start##
    <stderr>
    ##meclaw-stderr-end##
    
  • The had_stderr: bool header is always set, true or false.
  • Security boundary via params.sandbox (GH #35, completed in GH #85, for existing trees since GH #1235). Without a sandbox block of its own a bash cell runs under the default-deny profile: one instantiated from a template carries it in its config.json, a hand-written one gets it in the runtime view at the boot (config.md § params). Full FS access and full network take an explicit trust: "trusted". A relative params.cwd is the cell’s own root and stays writable under every restricted profile with a filesystem view, without a param of its own; an absolute cwd the block declares itself. With sandbox: {"trust": "restricted", ...} the shell starts under a Landlock filesystem allowlist, inside a fresh network namespace when network: "deny", under the declared limits (cgroup v2) and behind the declared syscalls filter (seccomp-bpf). Schema, the operating requirement for the caps and the fail-closed rule: config.md § params. Particularly relevant for a shell: under syscalls.foreign_signals: "deny" the script may only signal itself, so a kill $! on its own background job fails with EPERM.
  • Shell: /bin/sh -c <command>, started in params.cwd when set (GH #1234). shell as a param is deferred.
  • No persistent bash (cell.timeout: -1): dropped (architecture ruling 2026-06-08). bash is one-shot only, and this is not a deferred option.
  • Minimal input: {"command": "..."}.
  • Defaults: max_concurrency: 4, external_timeout_ms: 60000, max_bytes unset (the window decides), cwd unset (the colony’s process directory).

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, body and params. 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 header section (optional) plus top-level slots. The wire format is unchanged: the script still writes a header section. Colony interprets this as hop (the isolated cell output, expiring on the next cell emission), and the rest becomes message.body. The script does not write context, 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 } }
  • envelope carries everything the substrate puts around the payload: header (both compartments context and hop), target, trace_id, ttl, plus reply_to, parent_message_id and correlation_id when the message carries them.
  • body carries the body slots of the incoming message (messages, system, cell-owned slots) verbatim. The one exception (GH #907): when the contract declares consumes.body.attachments (required true or false), the cell reads every object entry of body.attachments from the blob store through the read-only AttachmentReader before the script runs, each read under params.attachment_timeout_ms (a positive integer, default 5000), and the entry on stdin then also carries data_b64 (standard base64, padded) or error: {code, message} with code one of timeout, not_found, store_error, bad_ref – on stdin only, never in the message, the log, hop/context or an emission; without the declaration stdin is byte-identical.
  • params is 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.

The order of the array is the promise a template builds on when something must go only after the answer. The splitter of talky (templates/talky/splitter, since GH #1255) writes the answer first, then the sections of its block, and last, for each section its knob answer_check names, one message on the template’s lane answer_check: hop {route, section, turn_id, section_written}, body {"messages": [], "section": "<name>", "verify_answer": "<the answer>"}, the answer as its reader got it. It comes with every answer, also when the model left the section empty or did not write it at all (section_written is then "0", else "1"), never on a tool round and never for an answer without text. Only the checker the template declares this way, and the parent binds on that lane, gets the text; no section on sidecar carries it. Empty (the default), nothing goes, and the output is byte for byte the output without the knob. Details: templates/talky/README.md, “The sent answer reaches its declared checker”.

Such a lane is silent while its knob is off, and the presenter‘s stage does the same (templates/presenter/stage, live-2 GR, GH #1357): with the switch screen_topics (default false) it emits on the lane of that name {"messages": [], "topics": [{"name", "purpose", "candidates"}]}, the topic table its decider is asked over (its own topics and the apps’ accepted ones, sorted by name, purpose = the manifest’s describe, candidates = [{"key", "describe"}] of the candidates in the manifest’s order, GH #1368; without candidates the field is absent). It leaves once on boot and on every change of the table, never per turn; a refused topic is no change. Its taker is the talky’s curator, and the parent draws the edge. Off, nothing leaves, and a parent without an edge for it sees nothing in the DLQ. Details: templates/presenter/README.md, “The topic table on screen_topics”. On the talky side (GH #1279) the table enters through the rim port in_screen_topics, and the verdict leaves through the rim port verdict, which the parent draws to the presenter’s in_verdict (never stamp); both are lanes of the assistant as well. Details: templates/talky/README.md, “Screen and partials”. Every switch of these lanes reads one way, the presenter’s (GH #1370): JSON true, "1", "true" or "on" is on, anything else off – in talky/route (clarify, judge_context, stamp_out), talky/screen_judge (enabled), curator/policy (quick_speculative, quick_instructions_slot, live_feed, delta_speculation) and collector/assemble (quick_speculative, delegation_inherits_stamp). A set writes its switches as JSON true, and a read of "1" alone kept them off without a word.

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_ms elapsed (the script ran too long): an error with error_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_code and header.had_stderr. The body is a tool_result turn with stderr in the bash sentinel-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 a header key whose value is not an object ({"header": 5}). The reject is total: in a multi-send, a bad shape in any message yields exactly one invalid_json reply and zero regular emissions.
  • script writes a JSON array without multi_send_capable: an error with error_code: "multi_send_not_declared".
  • script stdout valid, but contract.emits violated: an error with error_code: "contract_violation". This code validation runs always-on, unconditionally, independent of build profile and of colony.json strict_validation, because code is the only user-script-driven trust boundary; see meclaw-overview.md § “Schema validation: timing and scope” and config.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 without a block of its own runs under the default-deny profile, in its config.json when instantiated from a template, from the boot on in the runtime view when hand-written since GH #1235 (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. Since GH #844 the runner is started as <runner> -I <path> on that path, and only there. -I is Python’s isolated mode: the script’s own directory is not put on sys.path, and neither PYTHON* environment variables nor the user site directory reach the interpreter. The file lives in the shared temporary directory, so without the flag a module lying next to it (a json.py somebody else left there) would win the import over the standard library. The file is named meclaw-code-<pid>-<uuid>.py, and a factory that spawns a cell with such a script first removes every file of exactly that shape whose pid no longer belongs to a live process: the guard that deletes the file after the spawn does not run when the process is killed, and nothing else would ever clean up after it. Files of live processes and files of any other name are left alone. A script_path keeps <runner> <path> without the flag, because its directory on sys.path is part of what a path script is.

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 of max_concurrency resident 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 fresh globals namespace. What stays warm is the interpreter and sys.modules, and nothing the script writes into its namespace survives the message. A warm runner changes latency, never semantics: there is no warm_cache, no switch and no exception through which a script could carry something over after all.
  • resident: exactly one child, strictly serial FIFO. max_concurrency is forced to 1 in 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 as max_concurrency: 0 already is. The globals namespace does survive the message here, which is what resident is 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 first template that keeps a memory in resident is display/compose (display@2.7.0): its memory is a cache over its store and the web cell’s tree, so a killed child rebuilds the same screen.

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).

Idle (GH #1276). A warm or resident child ends once it has had no job for as long as the cell’s idle timeout says: cell.idle_timeout_ms, else the code type default of 30 minutes (1 800 000 ms, as on llm and resolved against the colony default idle_timeout_default_ms the same way). The clock runs only while a child is alive and no job is in flight, so it starts at the last answer; every slot of a pool has its own. A stopped child is as if it had never started: the next message boots a fresh one, paying one start and no error. cell.idle_timeout_ms: 0 arms the stop at zero: the child ends after every answer unless the next message is already waiting, so the cell runs like cold and pays a terminate on top. cell.timeout -1 or > 0 turns the stop off, and the child then lives as before GH #1276 until the cell is instantiated again. On resident the stop takes the child’s namespace with it; that is the same position as a killed child and allowed by the cache rule above. Before GH #1276 a child never ended on its own: 23–34 children per colony stayed until the restart.

Which mode to pick (GH #852, GH #1276, GH #1294). For every shipped cell a turn passes warm is the default; a rarely called cell (build, operation, night, timer – rare in templates/RUNNER-MODES.md) runs cold, because its warm child would stand 30 minutes after each call, and the apply broadcast wakes every warm cell that answers it (a member colony on meclaw 0.70.2 held 91 python children for 48 code cells after boot, 36 of them schema answers); warm on rare only with a written reason. The parser’s default stays cold, so a cell without the key behaves as it did before, but a template sets the mode on purpose. resident only for a cell that keeps a cache of its own durable state in RAM (the rule above). cold only with a written reason, for example a script that starts a subprocess and installs a signal handler, which a warm child would keep. Why warm: what cold costs is not the interpreter alone. Under the default-deny profile (network: deny, config.md § params.sandbox) every spawn also sets up a new user and network namespace (unshare(CLONE_NEWUSER | CLONE_NEWNET)), so a burst of messages is a burst of namespace setups, and the mailbox fills while they run (measured in lane 2 of the perf wave, debug build, no sandbox: cold p50 23 ms per message, warm 0 ms). A cell runs warm when it keeps no state between messages and reads stdin as text only – the harness hands the body an io.StringIO, which has no buffer and no fileno(); a cell that does either stays cold and says why. In a warm child fd 0 and 1 are the harness’s frame pipes, so a subprocess gets its own stdin and stdout (stdin=/input= and stdout=), never the inherited ones: it would read the next job’s frame or write its output as the answer. Every shipped code cell has exactly one row in templates/RUNNER-MODES.md (mode, where it sits on a turn, the reason), and crates/meclaw-cells/tests/code/gh1276_every_code_cell_has_its_runner_mode.rs holds the table against the configs; a new code cell gets its mode and its row in the same change. An idle warm or resident child does not live forever: the pool’s idle stop ends it, and the next message starts a fresh one.

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. On code the idle timeout therefore acts not on the cell but on the lifetime of its children (paragraph Idle above).

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.

Imports (GH #1026). Under warm and resident a script starts with the same sys.modules and the same sys.path as its cold run. The harness imports its own modules (io, json, traceback) without '' (the working directory) on the path and drops them from sys.modules before the script’s first run. It then sets the path that python3 -c or python3 <path> would have set. An inline script above the argv limit runs cold as -I <file> (GH #844), so its child also starts with -I. An import restriction the script puts in place itself, such as a finder on sys.meta_path or a cleaned sys.path, therefore decides the same in all three modes. Before, json, re and other modules were already cached, an import json in the script reached no finder, and the restriction did not apply warm. Between messages, what the script imported itself stays loaded; a module only the harness needs is neither in sys.modules nor bound as an attribute of a package that stays loaded (collections.abc on collections, for one). This also holds after an exception: what traceback loads while it formats one (ast with _ast and contextlib, unicodedata for a source line with non-ASCII characters) comes from references the harness loads at its start, and the import system, a finder of the script included, never sees those imports. crates/meclaw-cells/tests/misc_1/gh1026_an_import_filter_holds_in_cold_and_warm.rs pins this. One exception predates GH #1026: under warm and resident, __main__ is the harness’s module, not the script’s, so __main__.json and the harness’s other globals are reachable without an import, and pickle resolves __main__.X against the harness there.

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 (the body was cut, GH #83/#1085), optional redirects and final_url (only when at least one redirect was followed, GH #117).

params: max_bytes (explicit byte cap on the returned body, GH #83; unset by default since GH #1085, see below), 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, NAT64 64:ff9b::/96, 6to4 2002::/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 answering 302 Location: http://169.254.169.254/). Every hop, the first included, is re-screened before the connect, and max_redirects caps the chain (exceeding it gives too_many_redirects). A 3xx without a Location is a document with a 3xx status instead of a redirect, and goes back as a normal tool_result. A Location naming a foreign scheme, and a downgrade from https to http, are refused (invalid_redirect); the upgrade direction from http to https stays 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: true opens 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 endpoint 169.254.169.254 sits, 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. Neither io_error (which reads as “network problem, retry”) nor invalid_input (which reads as “the URL is broken”) fits a blocked target, and the only repair is a different target.

Size of the result (max_bytes, GH #83, GH #1085). 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. Its size therefore belongs to the window of the model that reads it: the body is cut to max_bytes when the operator sets it, otherwise to the tool-result share of the window the call names (input_soft in the message’s context or hop, 10 % of it at three bytes a token; behind a dispatcher the hop value arrives only when the dispatcher hands it on, see bash above), and with no window named it goes back whole. A cut is visible, never silent: text ends in ...[cut: <k> of <n> bytes shown; budget of the window], header.truncated: true, and header.bytes reports the full size the server sent, not the size of what survived. A body over the carrier ceiling (4 MiB, the largest body a colony mount takes) with no window to cut it to is refused whole: error_code: "too_long", the text names the size and the ceiling.

Conventions:

  • GET only. method, headers and body are deferred.
  • Minimal input: {"url": "..."}.
  • A non-2xx HTTP status is a NORMAL tool_result with an http_status header. The LLM or caller reads the status. Only DNS, connect, timeout, invalid input, the egress policy and the carrier ceiling produce error messages (io_error, timeout, invalid_input, target_blocked, too_many_redirects, invalid_redirect, too_long).
  • The input gate PARSES the URL (GH #110): missing, non-string, empty, syntactically broken ("not-a-url", "http://") and foreign-scheme URLs (anything but http/https, file:// included) are invalid_input; the text quotes a foreign-scheme URL (redacted, see below), an unparsable one it does not, it reads <url redacted> there. Before, the gate only checked presence and type, and a syntax error surfaced from reqwest and was mapped to io_error; an agent repairing its own call reads io_error as “network problem, retry” and thus applies the wrong repair forever. io_error stays what it should be: DNS, connect, transport. The URL itself travels on unchanged, since the parser never re-serializes it.
  • Every URL the cell writes out (the web_fetch ok log line, final_url, every error text) is redacted (GH #1375): a non-empty userinfo (name and password) and the values of secret-named parameters in the query AND the fragment (#access_token=…) become REDACTED, everything else stays byte-identical. Whether a name is secret is decided by its structure, not by a list of known names: percent-decoded, only the part behind the last ? (so a hash route #/cb?token=… is redacted like a query), split into words at camelCase boundaries and at _, -, . (accessToken → access, token; X-Amz-Signature → x, amz, signature). It is secret when one of the strong words password, passwd, token, secret, signature, credential, credentials, assertion, apikey, sessionid is a word of it or appears anywhere in the name written together (sessionid, authtoken), or when one of the weak words key, sig, pwd, auth, session is a whole word (refresh_token, sessionId, client_assertion yes; monkey, keyboard, signal, author no). code is deliberately not redacted: as a name it means a language, a product or a status far more often than an OAuth code. The result is parsed again; if it still shows the parser a secret value or userinfo (a tab inside a name, say), or the URL does not parse, it becomes <url redacted> whole; the request itself is unchanged.
  • TLS: rustls (the rustls-tls feature 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 also redirects: u64 and final_url: String (GH #117, so that a body which came from somewhere other than the requested url says so).
  • Truncation: max_bytes or the window’s share (GH #83/#1085, see above) cuts visibly; below it large bodies stay inline in text and are offloaded as a whole-body blob when needed.
  • reqwest::Client per 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 unset (the window decides), 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 the byte bound cut, GH #83/#1085, see below).

error_codes: io_error (a DNS or connect error), timeout (external_timeout elapsed), invalid_input (a missing or invalid query, or a max_results that is no positive integer). 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 (the default page size of the list, 10, GH #83; a call may name its own, GH #1085) and max_bytes (explicit byte cap on the text, GH #83; unset by default since GH #1085, see below).

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 hits than the page is trimmed in place: the JSON stays valid and carries the cut visibly where the model reads ("truncated": true, "total_results": <N> and "cut": "...[cut: <k> of <N> results shown; call again with a higher max_results]" in the object, because the hop header does not travel into the thread row). The page is the call’s: the tool argument max_results (a positive integer, also as a string) sets it for that search, and the param max_results (default 10, a full first page) is the page of a call that names none (GH #1085). The list is trimmed after the provider answered; the generic endpoint is asked only for q. header.result_count keeps the full provider count and header.bytes the full response size. The byte bound follows the identical web_fetch rule (GH #1085): max_bytes when set, else the tool-result share of the window the call names, else whole; a cut lands on a UTF-8 boundary with a ...[cut: <k> of <n> bytes shown; narrow the query or lower max_results] mark in text, and over the 4 MiB carrier ceiling with no window the response is refused (too_long). 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 optional params.api_key as bearer token. It expects the response {"results":[{"title","url","snippet"}]}. An empty api_key is no api_key (GH #270): templates ship it as "" and name a grant in credential_grant_id when the endpoint needs a key (#801), and with neither the cell sends no Authorization header 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 code cell beforehand.
  • Input: {"query": "..."}, optionally "max_results": <n> (the page size of this call, GH #1085).
  • Graceful on a non-conformant response: result_count=0 when the results key is missing or not an array. The body is ALWAYS passed through in text, with no hard error.
  • Header: operation: "web_search", result_count: u64, duration_ms, bytes. The http_status header is deferred here; parity with web_fetch would be more consistent, but is post-Slice-3.
  • Truncation: max_results and the byte bound (GH #83/#1085, see above) cut visibly; below them large result lists stay inline in text and are offloaded as a whole-body blob when needed.
  • reqwest::Client per 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 unset (the window decides).

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). Absolute, it must exist; relative (plain names, no ..), it is a directory of the enclosing hive that the spawn creates (GH #1234) — file and edit of one hive with the same value share one root, two hives never do.

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 and null mean the same) or "base64". In base64 mode text is standard-alphabet base64 (RFC 4648 §4, padded) of the raw bytes, the emission additionally carries header.encoding: "base64", and header.bytes stays 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: 0 and non-integer values are invalid_input. A window running past the end is clamped, and an offset at 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_error as 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. On write, list and stat they are invalid_input instead of being silently ignored, because a silently dropped offset on a write would let the caller believe in a partial write that never happened.
  • The default contract is untouched: without mode, offset and limit the old path runs exactly as before, including the absence of the encoding header.

Conventions:

  • target = reply_to: FileCell emits to msg.reply_to, with /colony/dead_letters as the fallback if reply_to is missing. Edges in the topology can override the target.
  • tool_call.text is JSON args: {"op": "read"|"write"|"list"|"stat", "path": "<rel>", "content"?: "<str for write>", "mode"?: "text"|"base64", "offset"?: <u64>, "limit"?: <u64 >= 1>} (the last three for read only, GH #106).
  • write without auto-mkdir: the parent dir MUST exist, and a missing parent is io_error. Symlink-safe via parent canonicalize.
  • write error texts are a contract (GitHub #79): io_error is ambiguous on the write path, so text names 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 prefix write failed: plus the named reason (permission denied, read-only filesystem, no space left on device, …). The error_code stays io_error in 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 is path_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: canonicalize resolves symlinks and the canonical path must live under base_path (on the write path, the canonical parent). The ordering is the point: canonicalize fails not_found on a missing target, so ../missing used to report not_found while ../existing reported path_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 an io_error). A .. inside the boundary stays ordinary path arithmetic. Absolute paths remain invalid_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). Absolute, it must exist; relative (plain names, no ..), it is a directory of the enclosing hive that the spawn creates (GH #1234) — file and edit of one hive with the same value share one root, two hives never do.

Conventions:

  • Ops: find_replace and insert_at_line. Patch is deferred, since it needs a separate diff-format design pass.
  • find_replace replaces ALL occurrences. The matches_changed header 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 with matches_changed: 0.
  • expected_matches, the expectation guard (GH #105): an optional argument of find_replace, an integer >= 1. When it is set and the actual match count differs, the file is not touched; the answer is error_code: "unexpected_match_count" and text names 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_changed as a report), and null counts as “not set”. expected_matches: 0 is invalid_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 stay pattern_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”. On insert_at_line the argument is invalid_input, since there is no match count there and silently ignoring it would fake a guard that never runs.
  • insert_at_line is 1-based and inserts BEFORE: line = 1 puts the content at the very start, line = file_lines + 1 at the very end. line < 1 or line > file_lines + 1 is invalid_input.
  • content is normalized to a whole line (GH #108): when content does not end in \n, the cell appends exactly one. Before, content was 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 called insert_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: empty content stays 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 \n still lands on its last line and the opposite would rewrite a line the caller never named.
  • Shares FileCell’s security boundary: the same base_path logic (extracted into meclaw-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, plus pattern_not_found and unexpected_match_count (GH #105).

proxy, a bridge outwards

Long-running. Bridges to an external chat platform or to a peer colony, or takes webhooks from outside. There are four 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), slack (Socket Mode, WebSocket push, since 0.1.12), meclaw (a peer colony, one POST to its mount) and webhook (a mount that takes verified POSTs from outside). One instance bridges exactly one platform. The chat variants hold a cursor for update offsets in cell.db, so that restarts do not process messages twice; the cell.db of the meclaw and the webhook variant stays empty.

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 in cell.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.db access.

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). Since GH #907 Telegram also reads message.document: the I/O task fetches the bytes through getFile and the download (each step under its own A-timeout, at most max_document_bytes), the handler commits them to the colony blob store (write_streaming under query_timeout_ms), and the emission is ONE turn with the caption as its text (empty allowed) plus one attachments[] entry {blob_id, mime_type, filename, size_bytes, sha256} and the header has_file: "1" – the document travels as a blob reference, never as bytes; without bytes (too large, download failed, blob write failed or no store: blob_write_failed, timeout, no_blob_store) the text carries the caption and [file "<name>" could not be stored: <why>]. Every other update (photo, voice note, sticker, edit) is acknowledged: the cursor moves past it and nothing is emitted. In the colony’s shutdown drain (GH #1253) the I/O task hands over no update whose poll returned only in the drain, and polls no further: the cursor never moves past an update that was not handed over, Telegram keeps it (confirmed is only what a poll with a higher offset acknowledges), and the next start fetches it again. An update handed over before the drain is carried on by the colony as drain work. The other platforms (Slack, meclaw, webhook) hold nothing back in the drain: what they still emit then is routed by the colony as drain work as well instead of being dead-lettered as shutdown_draining, until the drain is quiescent or shutdown_drain_timeout_ms cuts it off.

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 a send is due before the sealed bot token has arrived, error_code: "credential_pending" (detail “the bot token has not arrived from the vault yet”). 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, sealed via bot_token_grant_id or as a literal 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) and max_document_bytes (Telegram, default 20 MiB – the Bot API’s getFile limit; the turn carries only the blob reference; a positive integer).

Sealed bot token (GH #1059). bot_token_grant_id names the grant under which the vault delivers the bot token sealed. Required is this grant or a literal bot_token; with neither, the parse fails with “bot_token: required — name a credential_grant_id in params.bot_token_grant_id …”. Request and box have the form of llm (steps 1, 3 and 4 under “Sealed credential delivery”), but nothing is parked: on_start asks over the template’s edge, and the I/O half polls only once the box is opened, then with the delivered token. If no box arrives within credential_wait_ms (default 10000), because the question was lost, the broker is not spawned yet, the vault is locked or an error answer came instead of a box, the cell asks again, with a fresh recipient and a doubled wait up to credential_backoff_max_ms (default 300000), one log line per round. A box that does not open leaves the cell asleep until the next round, and a late box of a replaced round is discarded. The token lives only in RAM, so every respawn and every wake asks again. With a grant, bot_token is ignored (one WARN line at birth names the param, never the value) and never used, not even while the box is missing. Without a grant the literal keeps working, as a transition for one release. Error texts of the HTTP client are logged without the URL, because the Telegram URL contains the token. A reply that is to be sent before the box is answered at once with hop.error_code credential_pending, without a route and without text. That receipt is no message of the channel and has no caller: an edge that forwards everything of the cell without a route into a turn excludes it (hop.error_code != 'credential_pending'), otherwise the turn answers, the cell answers with a receipt again, and the loop runs as long as the credential round (GH #1339). The receipt then ends as no_route in the DLQ.

Typing indicator (GH #515, GH #1099). On every inbound message the cell sends sendChatAction typing every 4 seconds until the answer for that chat goes out, for at most typing_max_ms (optional, a positive integer in ms): the backstop of the cell that answers, so the chat shows a sign of life exactly as long as the answer may still come (the shipped telegram-connector names the cogny/brain backstop, the cell that answers a member’s chat turn). Without it the ceiling is the backstop this colony resolves for the proxy cell itself (cell.message_timeout, else message_timeout_default_ms, 121 000 by default); with no backstop at all (0/-1) the colony default still bounds a keeper whose turn was abandoned. Immutable.

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, max_document_bytes, because handler and I/O task share the one client built at birth, bot_token_grant_id, credential_wait_ms and credential_backoff_max_ms, because the question goes out in on_start and the clock of the rounds belongs to the I/O task, and typing_max_ms, read once at birth into the typing cadence. 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 token xapp-…, for apps.connections.open) and bot_token (the bot token xoxb-…, for chat.postMessage) are each required, sealed via app_token_grant_id or bot_token_grant_id or as a literal via ${VAR}, are immutable, and are redacted in Debug, in logs and in error messages, because the bot_token IS the bot’s identity in the workspace. Alongside them emit_to (required), base_url (default https://slack.com/api), the A-timeouts connect_timeout_ms/send_timeout_ms/query_timeout_ms, idle_timeout_ms (the idle deadline of the socket mode read loop, default 120000 = four missed pings at Slack’s slowest documented cadence; every frame resets the deadline and an elapsed one becomes ConnectionEnd::Transient feeding the reconnect machinery), envelope_dedup_secs (retention of the seen_envelopes dedup table) and thread_follow (default true). Optional bot_user_id (U…), used solely for the defensive self-filter R4.

  • Sealed tokens (GH #1059): grant, rounds, credential_wait_ms/credential_backoff_max_ms (defaults as for Telegram) and the ignored literal work as for the Telegram bot token above. With neither for a token, the parse error names the grant key (app_token: required — name a credential_grant_id in params.app_token_grant_id …, likewise for bot_token). The I/O task connects only once both tokens are there, because a connection before the bot token would take in events whose answers could only fail. Two grants are two boxes, the same grant for both tokens is one question. A send before the bot token gets credential_pending (detail “the slack bot token has not arrived from the vault yet”). Like every Slack param, the new ones are read only at birth.

  • Socket Mode instead of long poll: the I/O task fetches a short-lived wss:// URL via apps.connections.open, connects, and waits for the hello frame, which carries our own app_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 (a disconnect frame, 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.db table seen_envelopes).

  • chat_id is 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 the hop: 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 (the thread_owner table in cell.db, switch thread_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:

    RuleConditionEffect
    R1event.bot_id presentignore
    R2event.subtype == "bot_message"ignore
    R3event.app_id == our own app_id (from the hello frame)ignore
    R4event.user == params.bot_user_id (when set)ignore
    R5a channel message without a mention and without thread ownershipignore

    Ignored events are acknowledged anyway.

    A live lesson (2026-08-09, against the real Slack API): app_mention is routed selectively to the mentioned app, whereas message.channels reaches every subscribed app. Every channel message therefore arrives at the bots twice or more, carrying the same ts but different envelope_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 with bot_profile on bot-authored messages, and not on subtype: subtype: "bot_message" is classic-app behaviour, unreliable on its own, and stays only a second line of defence. R3 reads event.app_id, the sending app, never payload.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 params update path in the handler. Recorded as a deferred item (“β params overlay for the Slack variant”).

The meclaw variant is a third platform of the same cell type and not a new cell type. It is enabled via params.platform: "meclaw", and what it talks to is not a chat platform but another colony. One instance is one contract class and not one counterpart: many peers share one cell, and who is addressed travels with the message.

The contract block of an instance. Nothing in it is required: arrived carries a projected body that often has no messages[], and crossed an empty one, so a required field would break one of the three shapes.

"contract": {
  "version": "1.0.0",
  "settings": {
    "identity_header": { "type": "string", "secret": false, "default": "", "description": "The header the reverse proxy fills with the verified sending colony; empty accepts nothing" },
    "trusted_proxies": { "type": "array", "secret": false, "default": ["127.0.0.0/8", "::1/128"], "description": "Addresses or CIDRs whose identity_header this mount believes; absent is loopback, [] is nobody" },
    "boundary": { "type": "string", "secret": false, "default": "", "description": "This side's own name; it rides every receipt" },
    "auth": { "type": "object", "secret": true, "description": "Optional credential on every outgoing POST; its value and client secret come from the vault under value_grant_id / client_secret_grant_id" },
    "egress": { "type": "array", "secret": false, "default": [], "description": "Origins an outgoing frame may be posted to; absent or [] sends nothing out" }
  },
  "emits": {
    "body": { "messages": { "type": "array", "required": false } },
    "hop": {
      "route": { "type": "string", "required": false }, "peer": { "type": "string", "required": false },
      "boundary": { "type": "string", "required": false }, "lane": { "type": "string", "required": false },
      "fields": { "type": "array", "required": false }, "error_code": { "type": "string", "required": false },
      "peer_event": { "type": "string", "values": ["crossed", "refused", "deferred", "expired"], "required": false },
      "peer_origins": { "type": "array", "required": false }, "peer_speakers": { "type": "array", "required": false },
      "id": { "type": "string", "required": false }, "deferred_ms": { "type": "integer", "required": false },
      "tries": { "type": "integer", "required": false }, "last_error_code": { "type": "string", "required": false },
      "peer_frame_id": { "type": "string", "required": false }, "peer_sent_ms": { "type": "integer", "required": false },
      "peer_arrived_ms": { "type": "integer", "required": false }
    }
  },
  "consumes": {
    "body": { "messages": { "type": "array", "required": false } },
    "hop": {
      "route": { "type": "string", "required": false }, "peer": { "type": "string", "required": false },
      "peer_url": { "type": "string", "required": false }
    }
  },
  "ingress": { "carries_trace": true },
  "capabilities": ["network:proxy", "db:own"]
}
  • Instead of a long poll it holds a mount on the colony’s one listener. /<mount>/ takes exactly one POST whose body is a wire-version-1 frame, and the answer to that POST is the receipt. Who the sender is, the cell reads only from the header identity_header names; a frame that carries a sender field is invalid_frame. So is a frame whose projected body is no valid UBF body, for instance one left with none of the central slots system, messages, attachments: the mount accepts only what it can deliver, and a sender that leaves out the turn list reads that refusal instead of a crossed for a message that reaches nobody. Here it departs from web: with an empty identity_header, web stamps nothing and carries on, while this mount refuses every frame with invalid_frame. A peer mount with no header named would be an open door, and the reverse proxy in front is what sets the name. The header counts only on a connection from an address in trusted_proxies (by default loopback, 127.0.0.0/8 and ::1/128), and that is decided once per connection from the address the listener accepted, never from anything the request says. From any other address the header is treated as missing: the frame is invalid_frame, the receipt’s detail names params.trusted_proxies, and the local refusal carries no sender. A reverse proxy on the same host needs no configuration; one on another host is listed by its address. Who may reach the port at all is still the business of the proxy and the host.
  • Outbound travels over the entry edge, which stamps the lane as hop.route, the addressee as hop.peer and that side’s mount behind its own proxy as hop.peer_url via modifier.set_hop; the edge does this, not the cell. None of the three ever crosses the boundary, and a message without hop.peer_url is refused with peer_unreachable. The cell posts only to an origin its own params.egress lists, for example ["https://gateway.example"]; an entry is http(s)://host[:port] with no path and no credentials, and a malformed one is refused at boot by its index. hop.peer_url is written by whoever wrote the message, so the list is what keeps the frame and the credential at the gateways the operator named: an origin outside it, credentials in the URL or a scheme other than http/https is egress_denied, a refused receipt under this side’s boundary, decided before the token request and before any connection. Without params.egress nothing goes out (fail-closed since 0.46.0); the migration is one line per peer cell, "egress": ["https://<gateway-origin>"]. The contract block above is how an instance declares all this; without it the cell does not boot.
  • Inbound, the edge that carries arrived into the member’s channels stamps who the turn is with. context.channel holds the conversation, composed per counterpart ('<class>:' + string(hop.peer)), and since GH #834 context.counterpart holds the counterpart itself as an entity reference (peer:<...>), in the identity form peer:<colony>/<org>/<member>/<agent> from which affinity computes the participant reference its brief answers with as who (templates/affinity/README.md § Who is speaking). The collector’s brief leg asks the member’s affinity about exactly that key, once per turn, before the brain is called. The cell stamps neither key: which entity a sender is, is the wiring’s statement and not the frame’s. The firewall does not touch counterpart (unlike user_id, which it deletes on both pass exits).
  • Every arriving turn is peer (GH #847). After the lane projection the mount stamps every turn that carries an origin as origin: "peer"; what the sender claimed is in hop.peer_origins (per turn one of the five values user, assistant, tool, system, peer, in turn order). Only a claim from this set is stamped: a turn with any other origin (an unknown string, null, an object) stays as it came, and the frame is refused invalid_frame, as without the stamp. peer_origins and peer_speakers are always the mount’s word: a context key of the same name that the lane lets through is overwritten or removed. The other side can thus arrive neither as the agent’s own person (user) nor as its own answer (assistant), and an application that needs “the other side’s person” reads it from hop.peer_origins. A speaker and speaker_ref the other side puts on a turn itself leave the turn and stand in hop.peer_speakers (per turn {"speaker", "speaker_ref"}, a missing half null, a turn with neither null): the turn fields are set on this side only, from a checked identity, for instance by the collector from affinity’s answer or by an application gate that knows a trusted relay. The lane must still name messages[].origin: the stamp does not make a turn out of one without origin, and a frame carrying such a turn stays invalid_frame.
  • The edge that carries arrived into the member’s channels – the CHANNEL edge – may stamp two more keys, constant for every session the channel carries: context.tools_allow and context.tools_deny (an array, or a comma string) narrow the tool menu of those sessions. The collector reads them on in_turn only, keeps them in its session row and sends them with every brain call of the session as the body key tool_scope; the llm cell filters that one request, and the menu in the brain stays whole (GH #845). A present, empty list lifts nothing; to lift a channel’s scope, stamp both keys empty. A distinction per speaker or per status is not a scope – it stays a refusal in the gate. An application gate that forwards a room it trusts may also stamp context.roster_add ([{ref, name, identity}]) and context.roster_leave (a reference): the collector keeps the legend of the channel from them, system.roster, one line <ref> = <name> (<identity>) per participant, empty until a peer turn has stood in the session (GH #847).
  • The outgoing POST can carry a credential (params.auth, optional), so the reverse proxy in front of the far mount can tell the sender by what the operator issued rather than by the connection. Two forms, one at a time. The static form {"header": "X-Peer-Credential", "value_grant_id": "<grant>"} sends that header on every POST, with the value the vault delivers under the grant. The OAuth form {"token_url": "https://idp.example/realms/x/protocol/openid-connect/token", "client_id": "<client id>", "client_secret_grant_id": "<grant>", "scope": "", "audience": ""} performs the client-credentials grant (RFC 6749 § 4.4) against token_url: a form body with grant_type=client_credentials, client_id, client_secret and, when not empty, scope and audience (some providers require the latter). The token is held until expires_in minus a tenth of it, at most 30 seconds, and is sent as Authorization: Bearer <token> on every POST; a token response without expires_in is used once. A 401 from the far side is answered with exactly one fresh token and one retry, and a second 401 is peer_refused with the far side’s answer in the detail; with the static form the first 401 already is. A token endpoint that does not answer within external_timeout_ms, or answers with anything but a token, is auth_unavailable on the sending side: a refused receipt under this side’s boundary, and no request to the peer at all. No refresh token, no authorization code, no browser: the cell is a machine. value and client_secret are secrets and since GH #1235 no longer stand in config.json at all: a literal (GH #828) and a ${VAR} are refused at boot and at the mutation door, naming the key and never the value; the vault delivers them sealed under value_grant_id and client_secret_grant_id. Neither they nor a token appears in Debug, a receipt, a log line or cell.db. Verifying the bearer (the issuer’s JWKS, RFC 7517) is the reverse proxy’s job; the mount still reads the sender only from identity_header, and the frame carries no credential field.
  • The contract lives in params.lanes, one list per direction; a lane names route, fields, because and optionally context. Both sides declare the same lane, neither reads the other’s declaration, a field the lane does not name is refused and never stripped, and system in fields is a parse error. A turn that carries a blob reference (text_id, messages_id) is refused with lane_body_unsupported in both directions, even when the lane names the key: the receiving colony would resolve it against its own blob store.
  • Three kinds of emission, each with its structural part in the header slot. The receipts crossed, refused, deferred and expired carry route: "receipt" and the key peer_event; arrived carries the lane as route, the sender as peer and the projected body. Both sides book a receipt for every crossing: the sending side with the verdict from the answer to its POST, the receiving side with its own crossed next to arrived, under its own boundary and on the frame’s trace. A receipt does not choose its own address: without an edge on hop.route == "receipt" out of the cell it reaches nobody.
  • Delivery is at-least-once with an idempotent receive (GH #1012). The sender books the message in the outbox table peer_outbox of its own cell.db before the first attempt, under an id (a UUIDv7) and its clock at that moment (Unix ms); every attempt posts the same frame bytes with the request headers X-Meclaw-Frame-Id (the id) and X-Meclaw-Sent-Ms (the clock). The frame itself carries neither: it is the frame of 0.47.1, whose parser refuses unknown keys, so an older receiver reads it as before and ignores the headers. A reverse proxy in front of a mount must pass both headers through (hyphenated names, which proxies that drop underscore headers keep). Per target (hop.peer_url) the outbox sends serially and in order: a message behind one that is not through yet waits, it never overtakes. No connection, no answer within external_timeout_ms, or a 5xx is retried after 1 s, doubling up to 300 s; a 3xx, any other 4xx, a far-side refusal and every verdict of a lane or of egress stay final. A message that cannot cross at once reads exactly one receipt peer_event: "deferred" with the error_code that holds it (its own failed attempt, or the one of the message in front of it) and its id; a later success reads the ordinary crossed with id and deferred_ms. Past peer_retry_deadline_s the message is a dead letter peer_expired and a receipt peer_event: "expired" (error_code: "peer_expired", id, tries, last_error_code), never silent. A final verdict after booking (a 3xx or 4xx, the far side’s refusal, an answer that is no receipt, a credential that cannot be had) is a refused receipt with id and a dead letter peer_refused carrying the message; a verdict of a lane or of egress falls before booking and stays a receipt only. The receiving mount commits every accepted frame to peer_inbox (same cell.db, WAL) before it answers 200, and hands it on in commit order whether or not the sender still waits for the answer; a mount whose inbox cannot be written answers 503, which the sender retries. An unreadable X-Meclaw-Frame-Id or X-Meclaw-Sent-Ms is invalid_frame. An id the same sender already delivered (the inbox key is sender and id) is answered crossed with duplicate: true and raises no second arrival; a request without the id header (a sender before #1012) is accepted and never deduplicated. Rows not yet handed on are replayed at the next start, in arrival order. The arrival carries hop.peer_frame_id, hop.peer_sent_ms (when the request carried them) and hop.peer_arrived_ms (the receiver’s clock); the receipts carry id. A handed-on row stays pending for 30 s, because the colony logs the hand-on in a later writer batch the cell cannot see; a crash in that time raises it again at the next start, stamped hop.delivery_key (the inbox key) and hop.delivery_replay: true, and the colony drops a repeat its log already holds (GH #1015, stability.md § Delivery across a crash). The arrival and its receipt carry hop.delivery_key always. Both tables are state, not a log: a row is never deleted, only its state moves (pending, done; pending, sent, refused, expired). The durability covers a process crash (synchronous = NORMAL), not a power loss. Against a receiver before #1012 a newer sender still retries after a timeout, and that receiver does not deduplicate: a timed-out first attempt can arrive twice there.
  • The thirteen error_codes of this platform are a closed set and have nothing to do with the five Telegram codes above: lane_undeclared, lane_field_denied, lane_body_unsupported, peer_unreachable, peer_timeout, peer_refused, protocol_mismatch, invalid_frame, ttl_exhausted, auth_unavailable, egress_denied, peer_expired, credential_pending. protocol_mismatch, invalid_frame and ttl_exhausted are deliberately the words subcolony and the stdio bridge already use; auth_unavailable, egress_denied, peer_expired and credential_pending only ever fall on the sending side. There is no code for “accepted, then nothing carried it onward”, because the edge verdict is reached asynchronously in the colony task and never reaches the cell; it is an ordinary no_route dead letter in the receiving colony.
  • params are immutable for meclaw: no overlay, no update path, and a params slot in a message is an invalid_input. A boundary a message can rename is not a boundary, and whose header counts (trusted_proxies) and where the cell may send (egress) are part of the boundary. The cell.db of this variant holds only the two delivery books peer_inbox and peer_outbox (GH #1012): no cursor, no overlay, no allow-list.

params of the meclaw variant:

KeyTypeDefaultMeaning
platformstringrequiredAlways "meclaw"; selects this variant
mountstringrequiredThe name on the colony’s one listener; the peer mount is /<mount>/. [a-z0-9-]{1,64}, none of the reserved names
identity_headerstring""The header the reverse proxy fills with the verified sending colony. Empty means this mount accepts nothing
trusted_proxiesarray of stringsloopbackIP addresses or CIDRs (192.0.2.10, 192.0.2.0/24, 2001:db8::/32) whose identity_header this mount believes, judged once per connection; a v4-mapped IPv6 address counts as its IPv4 address. Absent means 127.0.0.0/8 and ::1/128, [] believes nobody. From any other address a signed frame is invalid_frame. An entry that is not an address or CIDR is a parse error naming its index
boundarystringrequiredThis side’s own name; it rides every receipt. Never a URL
emit_tostringrequiredWhere an arrived frame is emitted, as an absolute path
external_timeout_msu645000Operation timeout (hard rule 12) around each outgoing operation on its own, the token grant and the POST alike; an OAuth crossing with its retry can take up to four of them
query_timeout_msu645000Operation timeout (hard rule 12) around cell.db access via DbConn
lanesobjectrequiredThe contract in both directions: accepts and emits, one list of lanes each. An unknown key on any level is a parse error
authobjectabsentThe credential on every outgoing POST: either header + value_grant_id, or token_url + client_id + client_secret_grant_id with optional scope and audience. The vault delivers the secret sealed under the grant (GH #1105, the static form since GH #1235), asked for when the cell starts; until the box opened a message is booked and deferred with credential_pending, no token is requested, and the message leaves as soon as the secret is in. value next to value_grant_id, or client_secret next to client_secret_grant_id, is a parse error naming both keys. Both forms at once or an unknown key is a parse error naming the key; value/client_secret in config.json are refused in every form. Absent means the POST goes out without one
egressarray of stringsabsentOrigins (http(s)://host[:port], no path, query, fragment or credentials) an outgoing frame may be posted to, compared after normalisation (lower-case host, default port dropped, a trailing / ignored). Absent or [] sends nothing out (egress_denied). An entry that is not an origin is a parse error naming its index
peer_retry_deadline_su6486400How long the outbox keeps trying one message, in seconds; after it the message is a peer_expired dead letter and an expired receipt (GH #1012)

The webhook variant (GH #921) takes verified POSTs from outside, such as a repository host’s webhook or a calendar push channel, and hands each on as exactly one message on a declared lane. It is one-way: no answer with content, no queue, no retry.

  • It holds a mount on the colony’s one listener, like the meclaw variant. It takes POST /<mount>/ and POST /<mount>/<rest>, and <rest> travels as webhook.path ("" at the mount itself); any other method is 405 and reads no body. Each request goes in this order: the size (413 via max_body_kb, read only up to the limit), then the verification, then 202. A request that does not prove itself is 401, produces no message and leaves a refused receipt with error_code: "webhook_unverified".
  • verify.kind picks the method. hmac_sha256 checks HMAC-SHA256 over the raw body, exactly as it arrived, as 64 hex digits (either case) in the header verify.header, optionally behind the exact prefix verify.prefix (such as sha256=); re-serialising the JSON would break the signature. token compares the header behind the optional prefix (such as Bearer ) with the secret. Both comparisons run in constant time. none takes every request and requires a because text. Further methods (bearer JWT, mTLS) belong to the reverse proxy in front.
  • Since GH #1235 verify.secret no longer stands in config.json at all: a literal (GH #921) and a ${VAR} are refused at boot and at the mutation door, naming the key and never the value; the secret comes via verify.secret_grant_id (next point). An empty secret books one refused receipt with error_code: "secret_missing" and answers every POST with 503; the mount is never open. The secret appears in no log line, receipt, error text or Debug output.
  • Instead of verify.secret the secret can come sealed from the vault (GH #1106): verify.secret_grant_id names the grant under which it is delivered. With hmac_sha256 and token exactly one of the two is required; both at once is a parse error naming verify.secret_grant_id, and with none the grant is refused like the secret. Request and box have the form of llm (steps 1, 3 and 4 under “Sealed credential delivery”): on_start emits the request on hop.route == "credential_request" with hop.grant_id, the template’s edge carries it to the broker, and the box comes back as a sealed message. Only a cell with a grant takes such a message; without one it stays invalid_input. Until the box has opened the mount is registered but closed: every POST is 503 and books a refused receipt with error_code: "credential_pending", and no request is verified against an empty secret. If no box arrives within 10 s, the cell asks again with a fresh recipient and a doubled wait up to 300 s, like the Telegram variant. The secret lives only in RAM, and the first opened box holds for as long as the cell lives: it takes no further box. A rotation therefore means a respawn, which asks again.
  • An accepted request becomes one message to emit_to with hop.route equal to route and a fresh trace. Its body is a valid UBF body without a turn, because foreign content never becomes a conversation turn: {"messages": [], "webhook": {"path", "content_type", "headers", "raw" | "raw_base64", "json"?}}. raw is the raw body as UTF-8; if it is not UTF-8, it stands as raw_base64. json is present only for application/json with valid JSON, and invalid JSON is not an error. headers holds only the headers in headers_allow, names lower-case, repeated ones joined with , . Next to the message the cell books a crossed receipt.
  • The receipts have the form of the meclaw variant: route: "receipt", peer_event (crossed or refused), lane equal to route, boundary equal to the mount name, plus fields: ["webhook"] or error_code. Without an edge hop.route == "receipt" out of the cell they reach nobody. A message to the cell itself is refused with invalid_input, and so is a mount name already taken; the cell then stays up and serves nobody.
  • The four error_codes of this variant: webhook_unverified, secret_missing, credential_pending (only with verify.secret_grant_id), invalid_input. Idempotency lies with the receiver: most senders carry a delivery id in a header and retry on their own, and headers_allow lets it through. params are immutable, and the cell.db stays empty.

params of the webhook variant:

KeyTypeDefaultMeaning
platformstringrequiredAlways "webhook"; selects this variant
mountstringrequiredThe name on the colony’s one listener; the mount is /<mount>/. [a-z0-9-]{1,64}, none of the reserved names
verifyobjectrequiredkind (hmac_sha256, token or none), secret (${VAR}) or secret_grant_id (a grant id), exactly one of them with hmac_sha256 and token, header (required with hmac_sha256 and token), prefix (optional), because (required with none). An unknown key is a parse error
headers_allowarray of strings[]The request headers that travel with the message, compared regardless of case; verify.header is refused (with token its value is the secret)
routestringrequiredhop.route of every arrival: the lane it leaves on; never receipt (the lane of the cell’s own receipts)
emit_tostringrequiredWhere an arrival is emitted, as an absolute path; a consumer sets it on placement via override_params
max_body_kbu64256The largest body taken, in KiB, 1 to 4096; a larger one is 413

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 to cell.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 direct cell.db access.

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:

  • add is an INSERT; an existing schedule_id is an error, with no implicit upsert.
  • add with rearm: true (GH #904) replaces an existing row of the same schedule_id in any status (active, completed, removed) in place with the new order: active, iteration_n from 0, the same place in the firing order, no second row. Only the identical active order (moment, name, target, emission, catch_up) is left untouched. So a fixed id holds exactly one standing order.
  • add and modify of an at row take catch_up: true | false (GH #922; default false, a JSON boolean only, stored per row). What the flag does is described below under past firings. On a cron row catch_up: true is an error (invalid_params).
  • modify is an UPDATE of the carried fields; an unknown schedule_id is an error.
  • remove deactivates the schedule (a status update in cell.db, No-Delete-conformant, with no row deletion); an unknown schedule_id is an error.
  • trigger fires an existing schedule once, now, without changing its plan (GitHub #17). The op carries nothing but the schedule_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_id is an error. Since GitHub #1019 a trigger may name the schedule_name instead of the schedule_id ({ "op": "trigger", "schedule_name": "nightly-dream" }): a template that mints its id at instantiation (${uuid7:…}) otherwise leaves a caller outside the colony nothing but a look into the timer’s cell.db. The name must match exactly one active row; from there the path is the same as with the id, and the ack names the resolved schedule_id. No active row under the name is schedule_not_found, two or more are invalid_params (nothing fires). When the op names both, the id wins. Only trigger takes the name; add, modify and remove stay with the id.

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), invalid_params (GH #922: catch_up: true on a cron row, on add as on modify; meta.detail starts with catch_up_cron_unsupported), 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 that had already passed when it was ordered (catching up exists only for a stop, GH #922, below), 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.

A one-shot may name its moment as a lead time instead (#1315): after_ms (an integer > 0) on add or modify takes the place of at, and the timer sets at = now + after_ms with now the same clock read its at_in_past guard uses, so the lead cannot run out in flight. after_ms beside at or cron is a parse_error. The watchdog block’s retrigger sends its quiet time this way.

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.

Catching up, ordered per row (GH #922): a one-off schedule with catch_up: true whose at passed while the cell was not running, and which has not fired, fires exactly once on the cell’s next start (boot or respawn). Missed means at <= now at the start instant, the same boundary at which the plan filter otherwise discards. Several catch-ups fire in the order of their at, through the same firing path as a strike on time, and additionally carry the auto header late: true; scheduled_at is their own at, not the start. Afterwards the row is completed and is never caught up again. The mark is persisted before the emit (state-before-emit): if the process dies between the mark and the emit, the strike is lost and not repeated, once too few rather than twice. A strike that falls into the colony’s shutdown drain does not fire (GH #1253): the timer sees the drain before the mark, sets neither the mark nor iteration_n and does not emit, the row stays active, and the drain counts as a standstill: with catch_up the next start catches the strike up, without the flag the plan discards it, cron fires from the next occurrence. Whether the mark landed is the statement’s result to say, not the query_timeout_ms clock’s (GH #1181): a mark whose commit took longer fires its strike; only a statement SQLite really interrupted leaves the row active and plans the strike again. A modify with catch_up: true on a row that was already missed without the flag (discarded at the start but still active) catches it up at once, with the op’s snapshot and with late: true. Without the flag, discarding stays, and cron rows never catch up.

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:

HeaderContent
event_idUUID v7 of this single event
schedule_idunique UUID-v7 key of the triggering schedule
schedule_namehuman-readable label of the schedule
scheduled_atplanned time (RFC-3339-Z, UTC)
fired_atactual fire time (RFC-3339-Z, UTC)
iteration_non repeating schedules: 0, 1, 2, …
lateonly on a caught-up strike (GH #922): true; otherwise the header is absent

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.

What a timer accepts (params.accept_ops, GH #1019): without the param the timer accepts every op and the params slot, as before. A list of add, modify, remove, trigger and params narrows that: anything not in it is refused before the parse with error_code: "op_not_accepted" and changes nothing, neither in schedules nor in the plan. The op is named as the parser reads it: a body without op but with a schedule_id counts as add. The param is set at birth only and is not overlay-capable, so it cannot be lifted through the door it guards. Why: a timer whose inbox is reachable from outside emits a row created by add with itself as the sender. The memory-hive clock therefore carries accept_ops: ["add"] and has a single inbound edge, the one from its ./writer (GH #1229): the clock_op lane at the rim ends at the writer, which turns a trigger from outside into a one-shot order of its own and refuses every other op as op_not_accepted; otherwise an add from outside could reach every inner cell behind the sealed rim.

Watchdog contract (R-AG-1, GH #1096)

Polling is not a timer’s job, and neither is a tick over unchanged data. A watchdog is one order to the timer, in one of three forms, all built from the ops above under a fixed schedule_id with rearm: true, so that one id holds one standing order:

  • one-shot: add with at, for the moment something becomes due (a grant’s expires_at, the earliest resend of an unconfirmed pack). The event that moves the moment re-arms the order; with nothing ahead, nothing is armed. It carries catch_up: true, so a moment missed while the colony was down fires once on the next start instead of never; the shutdown drain counts as down (GH #1253).
  • calendar: add with cron, for a real calendar point only (a digest at eight, a window at night). A cron at a minute or five-minute cadence that asks whether anything changed is a defect: the change has a name, and its edge is the answer.
  • retriggerable (debounce): a one-shot at now + quiet, re-armed by every event, so it fires once after the last one. A nightly run is this form too: the first activity of the day arms it for the night slot, and an empty day arms nothing.

A one-shot cannot be ordered in the past: at_in_past refuses it (above), so a caller whose moment has already come does the work at once instead of arming. A remove of an order that is not active (never armed, or already fired) answers schedule_not_found; the caller wires the timer_op_error lane back or re-arms instead of removing.

A template’s code cell imports nothing, so the Python form of the contract travels as a copy between the markers # >>> watchdog v1 and # <<< watchdog v1. The canonical text is docs/watchdog-v1.py; pure functions that return op objects, and the timer cell sends. crates/meclaw-cells/tests/misc_1/gh1096_one_watchdog_block.rs holds every copy under templates/ byte for byte against it and runs the ops it builds through the timer’s own parser. Repositories outside this tree copy from the same file.

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 http over HTTP plus JSON-RPC with no persistent stream, and on stdio it 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 direct cell.db access.

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), "endpoint_unset" (GH #489, see below: no provider was named and nothing was called) and "credential_pending" (GH #1235: a cell with auth.bearer_grant_id before its sealed box; 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 optional auth.bearer_grant_id (GH #1235): the grant under which the vault hands the cell its bearer, sealed. The cell asks at start on hop.route == "credential_request", runs the handshake only with the opened token and answers every round before it with credential_pending; every round without a box asks again. auth.bearer takes no ${VAR} (boot error), and a token next to the grant is a loud reject; 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 without a block of its own runs under the default-deny profile, instantiated from a template and since GH #1235 hand-written as well (at the boot), 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 #1235: a hand-written harness cell without params.sandbox also runs under the default-deny profile from the boot on, under the same boundary as code and bash; the state before GH #85 takes trust: "trusted".

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:

FormLaneTargetBody
replyOutputSink (requester’s trace)msg.reply_to ?? msg.target{"header":{"subcolony_event":"reply"},"messages":[…from the child…]}
errorOutputSink (requester’s trace)msg.reply_to ?? msg.target{"header":{"subcolony_event":"error","error_code":…},"messages":[{"origin":"assistant","type":"text","text":<detail>}]}
unsolicitedOriginSink (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:

KeyTypeDefaultMutable (β)Meaning
rootstringrequirednoFilesystem root of the child colony. Canonicalized at parse time (existence and is_dir), like harness.workspace_root
commandstring"meclaw"noThe child binary. Explicitly configurable, so version skew is a config decision
envobject{}noExplicit environment of the child
env_passthrougharray["PATH","HOME","USER","LANG","TERM"]noSurvives env_clear: true, the secret isolation of the child colony
context_inobject{}noExplicit 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_tostringnonenoOptional origin lane for uncorrelated child egress frames
boot_timeout_msu6430000yesA-timeout on the ready frame
request_timeout_msu64120000yesA-timeout on the correlated reply. Generous, because a child colony may contain an llm cell
external_timeout_msu6430000yesA-timeout around every stdin write
query_timeout_msu645000yesA-timeout for cell.db ops
kill_grace_msu645000noSIGTERM 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:

callerrecognised bymay
user channelreply_to == None, a source message. No edge can produce one, because the colony stamps reply_to on everything a cell emitsput, rotate, unlock, lock, status, revoke
brokerreply_to == params.brokeruse, status, revoke, deliver
everyone elsenonenothing. 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.

Since GH #1058 a vault also unlocks itself the same way from an explicitly chosen key source that needs no user: key_source: "systemd-cred" (the systemd credential $CREDENTIALS_DIRECTORY/<credential_name>, never on disk in the clear with LoadCredentialEncrypted=) or "plainfile" (a 0600 file, the path without a unit). auto and prompt do not: a woken vault stays locked by default, and whoever wants the self-unlock names the source. When both unlock_env and such a source are set, unlock_env wins. If the source yields nothing (directory unset, file missing or too open), the refusal is again loud and named: an ERROR line and an invalid_input whose message names the source and the credential_name; the vault stays locked.

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:

keytypedefaultmeaning
brokerstringrequiredThe 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_sourcestring"auto"auto | prompt | systemd-cred | plainfile. Names a source, never material
credential_namestring"vault_key"file under $CREDENTIALS_DIRECTORY for systemd-cred
key_filestringnonerequired for plainfile. Refused if group or others can read it, the same answer ssh gives for a loose private key
unlock_envstringnoneGH #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_msu64nonere-lock after this long
sealed_neighborsarray[]further expected edge neighbours for the attestation
external_timeout_msu645000A-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, or an explicit key source that yields nothing, where the message names the source and the credential_name), 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; after each such answer it goes to sleep, the next message wakes it, and the wake tries the open again, GH #1082, as with the degraded store).

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, a meta tag naming the socket (meclaw-live), the container the LiveView client joins on, one small style block, and three script tags, the two bundles and @client/boot.js, which builds the socket. 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.

The shell carries no inline script and runs under script-src 'self' (since web@2.2.0, GH #867). The boot used to be an inline block with the socket URL in its text, so its hash differed per deployment path and no proxy could list it once; now the URL is a <meta> and the boot a file from the page’s own origin. A page that brings inline script of its own needs its hashes listed — display publishes them in templates/display/csp.json, together with the client of colony-view, the code view it shows by default; canvy publishes its own in templates/canvy/csp.json. style-src needs 'unsafe-inline', and base-uri needs 'self' for the shell’s <base>; the proxy sets the header, the cell sets none.

“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 (as <meta name="meclaw-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 legs are still written in one go, one database call and one transaction per bundle (GH #1030); that is the write path, not an atomicity promise. Should the commit at the end fail, every writing leg answers invalid_input. 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. Since web@2.2.0 two more definitions are refused with invalid_input, by the same check at component.define and for every seeded component: an editable prop typed "html" (what one viewer types would reach every other viewer as markup), and a template whose static text carries an event-handler attribute (a name that begins with on, followed by =; data-front= and aria-controls= are not handlers) or a javascript: URL, read the way a browser reads it, with character references decoded and tabs and newlines dropped. Because those rules read the template’s own text, a {{…}} may not stand where that text would run on through it, and the parser refuses the template (invalid_input, at definition and in the seed): inside a tag or attribute name (<scr{{x}}ipt>, on{{x}}click=), anywhere in a tag outside a quoted value, in an unquoted value, in a comment, in the end tag of a script, style or text element, and in a URL attribute before the template’s own /, ?, # or scheme (href="java{{x}}script:…") — there a value has to be the whole attribute value. {{children}} stands between tags only. The parser reads the markup as a browser’s tokenizer does: textarea, title, xmp, iframe, noembed, noframes and noscript are text up to their own end tag, </ before a non-letter opens a bogus comment, and <!--!> ends no comment. Whether these elements, script and style switch the tokenizer to text depends on the page around the template (inside svg or math they do not), so a {{…}} in their text has to fit both readings. A template ends between tags and outside svg and math, because what follows it on the page is read on from there; <plaintext>, a <![CDATA[ section and a <script> or <style> inside svg or math are refused. So are the SVG animation elements animate, set, animateMotion and animateTransform (they set the attribute attributeName names to a value from to, from, by or values, which no rule reads as that attribute: <set attributeName="href" to="javascript:…"> is a link that runs code), the attribute srcdoc (its value is a whole document the browser reads as markup again), and <!-- in the text of a <script> (after it the browser reads escaped script data, which can run on past the template’s end). A component reaches the cell through a LiveView binding, never through inline script. <script> itself stays allowed; whether a page runs inline script is the Content-Security-Policy’s decision.

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. For the same reason the header counts only on a connection from an address in params.trusted_proxies (by default loopback), decided once per connection from the address the listener accepted. A connection from anywhere else is served in full, page and socket, and its events carry no hop.user_id: absent, not empty. X-Forwarded-Prefix, the header that moves the base a page is served under, is read from every connection as before; its narrow grammar is what makes it harmless.

The socket carries topics that do not belong to this cell, and since web@2.1.0 there are two kinds of them. A topic whose name starts with voice: or page: is not a page and is not answered here at all: the loop asks the process’s mount table for a link, forwards text one way and binary the other, and repeats a close with its code. What the frames mean is the business of the cell behind the mount, so a wrong frame produces that cell’s refusal rather than one invented on the way. What differs between the kinds is a row in a table and not a branch: voice:<call> reaches the mount voice when the join names none, carries binary both ways as the event audio, and is capped at four live links per socket; page:<page> reaches the mount browser when the join names none, carries binary out of the cell only as the event image, and is capped at eight. A binary frame from the client on a page: topic is dropped, because a page sends pointers and keys as text and never a picture. The count is kept per kind (too many voice topics on this socket, too many page topics on this socket), so a screen already holding four calls can still open a window. The join payload is flat: mount chooses the door, and everything else the door hands on to the cell unread, one level deep. Which doors a join may reach is params.link_mounts (since web@2.2.0): empty, the default, is every mount in the process, as before; a list refuses a join to any other name, the kind’s default included, with this display links no topic to "<mount>" (params.link_mounts: …). The page names the mount, so without a list every cell with a link door is one join away from every display socket. display sets ["voice", "browser"].

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 is resolved against the fixed schema (GH #822: a missing column with a DDL default takes it, any other difference is refused), 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.

FormMeaning
{{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.

Escaping keeps a value inside its element or attribute; it does not decide what the value means there. So since web@2.2.0 the parser records for every substitution where it stands, and the value has to fit that place. In a LiveView binding (phx-click, phx-click-away, phx-submit, phx-change, phx-blur, phx-focus, phx-keydown, phx-keyup, phx-window-*, phx-mounted, phx-remove, phx-connected, phx-disconnected, phx-viewport-top, phx-viewport-bottom) only an event name fits, [A-Za-z0-9_.:/-]{0,64}: the client reads a value that starts with [ as a list of JS commands. phx-value-* and phx-target stay plain text, because the client executes nothing there. In href, src, action, formaction, poster, xlink:href and data a relative URL or one with the scheme http, https, mailto or tel fits, and no control character. In style a plain CSS value fits, [A-Za-z0-9 _.,%#+-]{0,128}: no ;, :, parenthesis, quote or backslash. In the body of a <script> or <style> an escaped value fits only as such a plain CSS value, since there it is code. Inside a tag outside quotes only a token fits, [A-Za-z0-9_.-]{0,64}, and a place a conditional leaves in doubt takes that narrowest grammar. A prop typed "int" renders an integer, a JSON integer or up to fifteen digits with an optional minus, wherever it stands. A value that does not fit renders empty: the same trade as the silent escape, a missing word rather than a page nobody can see. A raw value is raw only between tags and in the body of a <script> or <style>; inside an attribute it is escaped and fitted like any other value.

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:

KeyTypeDefaultMeaning
mountstringrequiredThe 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_headerstring""The request header whose value rides every semantic browser event as hop.user_id. Empty stamps nothing
trusted_proxiesarray of stringsloopbackIP addresses or CIDRs whose identity_header this display believes, judged once per connection. Absent means 127.0.0.0/8 and ::1/128, [] believes nobody; a connection from elsewhere is served without hop.user_id. An entry that is not an address or CIDR is refused naming its index. Changes at runtime, read on the next life
link_mountsarray of strings[]The mounts a voice: or page: join on this display’s socket may reach. Empty is every mount; a list refuses any other name, the kind’s default included. Every entry is a mount name, and one that is not is refused naming its index. Changes at runtime, read on the next life
external_timeout_msu645000Operation 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.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. Every refusal of the admission (admit) – a bad session, mode, encoding or a rate the providers cannot serve, at the socket and at the link door alike – is also said on a WARN log line voice: upgrade refused with the door, the status and the detail the client got (GH #1373): on a telephone bridge the 400 lands in the log of another host. A request the router refuses before that (a query it cannot read such as sample_rate=abc, a request without upgrade headers) writes no line. A duplex session that fails once the call is up is said on a WARN line voice: the duplex session failed with the session id and the error, next to error duplex_failed and the close 1011.

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 2500 ms; 0 cuts on the frame, the behaviour before this existed). Draining is not waiting in silence: since voice@2.0.3 the cell pushes 20 ms frames of digital silence into the recognition session from the release on, at the rate the connection negotiated, until the provider ends the turn or the grace runs out. Since voice@2.0.4 the cap is 2500 ms rather than 1500: the grace counts from the release but has to pay for the time since the last spoken WORD, and that is a measured 1795 ms – the 400 to 700 ms a model quotes is only what it spends AFTER its endpointing threshold. An endpointing provider measures the end of a turn in the AUDIO STREAM – Deepgram Flux after eot_threshold, 0.7 s – and a client stops sending about 120 ms after the key comes up, so a boundary that merely waited never got an end of turn at all and was cut at the cap with the interim, which lags the audio. The client’s own frames outrank the tail: while it is still draining its capture graph the next silence frame waits, so no zeroes land between the last two words of a take. The silence frames run on a fixed 20 ms raster from the release: a frame woken late does not shift the ones after it, and missed slots are skipped, never sent in a burst (GH #1199). 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 ten are source emissions to the cell’s own path, and the out-edges decide where they go.

LaneBodyHeader
partialmessages: [{origin: "user", type: "text", text}]route, session_id, call_id, platform: "voice", eager, mode
spokenthe same shape, origin: "assistant", cumulative over the open turnas partial, plus speaker: "assistant" and engine; duplex mode only, and the lane is ordered the way the partial lane is (emit_partials), unless duplex.gpt_live.emit_spoken is set (false turns off this lane only) — the client frame of the same name travels regardless
turnthe same shaperoute, session_id, call_id, turn_id ("<session_id>#<n>"), platform, mode; in duplex mode also happened_at, t_person_end_ms (end of the caller’s last words before the answer: a backchannel in the middle of the answer does not move it, and a barge-in’s run belongs to the turn it opens; missing in a turn without them) and t_first_audio_ms (when the first audible audio frame of the answer to them went out to the client – silence the provider streams before the answer does not count –, after a barge-in the first audible one after the new block’s first transcript fragment and not the tail of the interrupted one; never before t_person_end_ms; missing where no audible answer audio went out inside the turn), both in milliseconds on the call’s clock from the start of the session. t_person_end_ms is the end_ms of the transcript fragment with which the provider reports the end of the words (gpt_live has no end-of-speech event of its own); t_first_audio_ms reads the clock the tick reads (the last stamp a fragment set plus the monotonic time since it arrived) when the frame is sent, to the millisecond; audible means above the barge-in level (duplex.barge_level_dbfs). Only audio after a transcript fragment counts: answer audio that arrives before the caller’s last fragment or before a new block’s first fragment leaves the mark to the next audible frame after it. The gap does not hold the delivery time of the incoming transcript, because the clock runs from the arrival of the last stamp. The answer’s transcript does not serve: a duplex model stamps its words on its 200 ms raster where the caller’s end, measured 12 of 15 turns at 0 ms
speak_endmessages: []route: "speak_end", session_id, call_id, speak_id, reason (done | cancelled | failed), platform
delegationmessages: [{origin: "user", type: "text", text}]as turn, plus delegation_id and offset_ms; duplex mode only
renewedmessages: []route, session_id, call_id, platform, mode, engine, renewal_n (from 1), renewed_at (epoch milliseconds); duplex mode only: the provider session was renewed within the same call (renew_after_ms)
seed_askmessages: []route, session_id, call_id, platform, mode, engine, reason (start | renew | recover), renewal_n (0 for the call’s first session, else the number of the renewal or recovery); duplex mode with seed_wait_ms only: a session of the call opens (at the ring, at the media connection, as a renewal or as a recovery) and waits for its seed
usagemessages: []route, session_id, call_id, platform, mode, engine, seconds (the call’s total over every provider session), final, and on the final one complete (false where a session’s last meter reading stands in for a close it never sent); duplex mode with emit_usage only
call_markmessages: []route, session_id, call_id, platform, mode, engine, elapsed_s (seconds since the connection opened); duplex mode with call_marks_s / call_mark_every_s only
errormessages: [], meta.detailroute: "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 two lanes. 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 third inbound form exists. The second is in_advise, and it exists only behind a duplex provider (below). 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, and none the cell held and lost — since GH #1016 an answer for a call that ended goes to the colony as a session_ended dead letter instead of onto this lane), 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), duplex_failed (the duplex provider failed, and the conversation is over with it), duplex_warning (a fault the session survived), bad_section (an in_advise with a section that does not exist, or with one that arrived with no words in it; renew needs none), wrong_engine (an in_advise or in_seed to a cascade session), missing_renewal_n (an in_seed without hop.renewal_n).

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 comes sealed via a grant or as a literal api_key via ${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.

Sealed provider key (GH #1059). Every slot optionally carries credential_grant_id and credential_wait_ms (default 10000) in its sub-object (stt.credential_grant_id, tts.…, duplex.…); cell-wide, credential_backoff_max_ms applies (default 300000). A provider that needs a credential needs a grant or a literal api_key, otherwise the parse fails with “.api_key: required — name a credential_grant_id in params..credential_grant_id …”. An unresolved ${…} counts as no literal, and echo as well as tts: null need nothing. on_start asks once per grant; request and box have the form of llm (steps 1, 3 and 4 under “Sealed credential delivery”), and rounds, late box, respawn and ignored literal behave as for the Telegram bot token of proxy. The cell takes sessions from the start. A slot without a key lets the session wait up to credential_wait_ms for its box and then refuses it with a named reason. The slots are independent: a delivered stt key starts recognition while tts still waits. The wait happens only before the first box of a life; after that the key is in RAM.

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.

A duplex provider is the third seam, and it replaces the other two. DuplexProvider holds one session that carries audio in both directions and says beside it what was said: the model hears the caller itself and answers with a voice, instead of a recogniser making text, an assistant answering it and a synthesis turning it back into audio. Since voice@2.1.0 there are two of them, gpt_live for a hosted live session over one WebSocket and echo for the wire without a model. A duplex block excludes stt and tts, and without one nothing about this cell changes.

What does not come with it: no cell type of its own, no webhook, no second door and no reconnect. The model sits behind the same traits a recogniser sits behind, audio still terminates in the I/O half, and one client frame becomes one frame to the provider, unbuffered and unpaced. A session is the conversation, and a second socket would be a second conversation without the first: when the provider fails, duplex_failed goes out on the error lane, the client gets 1011, and the connection ends. When the provider is gone, so is the call. The one exception is the host’s to set with duplex.gpt_live.recover_max (GH #1377, default 0, off): when the provider ends the session in charge mid-call (whatever the reason, in good order too; not the cell’s own close, a takeover or the client hanging up), the cell opens a new session of the same call at once, which asks for its seed afresh with seed_ask reason: recover and reports its start on renewed like a renewal, so the colony sends the handover; the dead session’s Closed does not reach the handler, its usage still counts. A renewal that was opening retires. At most recover_max recoveries per call; when the session in charge ends after that, duplex_failed goes out on the error lane exactly once (with the cause and the number of attempts), also on an ordinary end, and the client gets 1011. A fault the session survives is duplex_warning.

A duplex session runs on a clock of its own. Turns are cut on the model’s timeline, so a caller who stops talking closes a turn only when time passes – and on a line where nobody talks, no fragment arrives to carry that. tick_ms (1000) is the interval that does: the connection hands the turn machine the time on every tick. Until 2.2.0 the running meter session.usage.updated did that job, and it does not hold – on a session that hears only silence the meter arrived at no point inside 45 s over four measured runs, which is exactly the case it was relied on for. The meter is now read for the context window and for nothing else. A watchdog timer is not polling; it is a timeout timer, and the event-driven rule is untouched by it. The promise that a turn closes between one and two gaps after the caller fell quiet holds while tick_ms is at most turn_gap_ms, and a zero is refused at the parse.

The session also asks its own socket whether it is still there. Every keepalive_ms (8000) the gpt_live adapter sends a WebSocket ping, and the pong carrying its own payload – and only that one – resets provider_idle_timeout_ms. Without it that deadline could not tell a caller who paused from a wire that had died: session frames alone do not say which of the two a silence is, and thirty seconds of it ended the call. Eight seconds puts the third ping at 24 s, inside the shipped deadline of 30 000 ms, so two pongs may go missing before a call is given up. A ping somebody sends the cell still counts for nothing, or a proxy pinging a dead upstream would hold a call open for as long as it likes.

The same tick carries a deadline for delegations. A delegation this colony has not answered within delegation_grace_ms (12000) is closed by the cell: one Commentary append on that delegation_id carrying delegation_fallback, and the delegation leaves the open list, so the sentence is said once rather than once per tick. Measured, an unanswered delegation leaves the caller with one holding sentence and then 55 to 58 s of silence, with no provider-side timeout inside a minute. The shipped grace is about twice the slowest answer the same measurements saw end to end – 6 000 ms to reach the cell plus 779 ms for the model to acknowledge the append – because six observations are not a distribution. The fallback is an instruction and not a script: a live model paraphrases what it is given.

Each delegation_id gets exactly one spoken answer through to the model, the first. One delegated utterance can start two rounds – the delegation’s own and the turn’s ordinary one, which takes the delegation_id over from the window – and either can send a fact on it. A second spoken in_advise on a delegation_id already answered – an interrupt too, which carries a spoken fact – is not said; the cell writes a WARN line and nothing on the error lane, because nobody did anything wrong. The deadline’s fallback does not count as an answer: a fact after it in the same turn is still said.

hello carries duplex, and in that mode stt and tts both name the same provider, because one provider does both. The field is additive to meclaw-voice/1: a client that does not know it reads the frame it always read. GET /<mount>/info declares the same thing.

Two outbound lanes exist only here. spoken is the mirror of partial for the other side: the cumulative text of what the model has said in the open turn, and a frame of the same name goes to the client even when the lane is not ordered; the frame names the turn it is filling, the lane does not. delegation leaves when the model hands work to the backend; it carries delegation_id, offset_ms and the user text of the open turn, and it carries no task text, because the session gives none out. Every emission out of a duplex session stamps hop.engine: "duplex"; a cascade stamps nothing, the key is absent there.

in_advise is the inbound lane that exists only here: what the topology wants said to the model mid-conversation without anybody reading it out. hop.section picks the channel. fact becomes a comment the model speaks in its own words, context a thought it reads and does not say, correction an instruction that changes how it holds itself. A text over 1800 characters is split at paragraph boundaries, and every part gets its own event_id. A section that does not exist is bad_section, and the same message to a cascade session is wrong_engine, because a cascade has no channel an aside would fit into. A section that does exist but arrives with no words in it is bad_section too: it is refused rather than dropped quietly, because an aside that vanishes looks, from the outside, like a model ignoring it. An in_speak is a comment here too rather than a synthesis: the model paraphrases what it is handed instead of reading it out. A context section that carries hop.renewal_n is the handover to a renewed session (since GH #896): it reaches the new session at once, before its ear hears the caller; the call goes over at the next quiet moment of the line (no spoken section open, spoken_quiet_ms without the model’s audio and without a transcript from either side), at the latest spoken_cap_ms after the block, and without a block after renew_grace_ms. What the old session still says reaches the caller until the new one speaks, never both at once; a block for a renewal that failed is dropped. An answer to a delegation of the old session is spoken by the new one without its delegation_id, a handle it never saw. Two more sections exist in duplex mode (D48): interrupt is the guard stepping in, an instruction (correction) followed at once by a comment (fact) with the same words and the same delegation_id, so the model changes course and says so; renew needs no words and opens the call’s next provider session at once, which asks for its seed again (seed_ask with reason: renew) and takes over at the next quiet moment like every renewal, while the old one keeps talking and keeps its open delegations; a renew while a renewal is opening whose seed is still to come and which has not started joins it (that seed carries the conversation as it then stands), a renew after its seed was answered or after it started (it then drops any later seed) is a new boundary and opens the next renewal right after its takeover, so the fact of the turn in between reaches a session. Without seed_wait_ms a renewal opens without a fresh seed, on its instructions alone; the cell then acknowledges a renew with a warning in the log. hop.at_turn_end ("1", 1 or true) on any section holds it until the caller’s next turn closes and then sends it, before anything new; several held renew at the same boundary are one renewal. A held answer to a delegation leaves the delegation open until it is sent: the fallback (delegation_grace_ms) still falls, and whether the answer is a straggler (GH #728) is decided when it is sent. At most 16 sections wait per session; beyond that the oldest is dropped with a warning, an interrupt as a whole, and its message goes to the colony as a session_ended dead letter. When the call ends before a caller’s turn closes, none of it goes into the ending session: every held message goes to the colony as a session_ended dead letter (GH #1016), a held renew is dropped with a log line.

in_seed is the second inbound lane that exists only here, and the answer to seed_ask: the history a session opens with (the model’s session.input). The body carries messages[] with {role, text} (origin where role is missing), roles developer, user and assistant; any other role or an empty text is left out. The call is addressed by context.call_id, context.session_id or hop.call_id, and without one of them it is missing_session, to a cascade wrong_engine. The answer gives back the renewal_n of its seed_ask on hop.renewal_n; without it, it is missing_renewal_n. It is delivered only to the session that asked: 0 to the call’s first session while it is in charge, any other number to the open renewal of that number; a session that has started drops it. A late answer, say to renewal n while n+1 already waits, is dropped and logged. It needs no live connection: the seed may reach a session opened at the ring that is still parked. Where neither a parked session nor a connection holds the call, it is unknown_session. The session waits at most seed_wait_ms from the moment it was asked for and then starts without one, logged as seed_late; a seed after the start is dropped. Over 128 messages or 8,192 estimated tokens (characters/4: the cell counts for itself and does not read the sender’s est_tokens) the oldest go first, logged as seed_trimmed, and seed_used names the count and the estimate. instructions stays required; the seed does not replace it. A renewal asks again (reason: renew), and the old session keeps talking while the new one waits.

greeting is the one line that comes before the caller does. Not empty, it goes out once after the session started, over the commentary channel and without a delegation: measured, a comment is spoken reliably and an instruction only sometimes. What it holds is a direction and not a script, because a live model paraphrases what it is given.

What cuts the turns here is the model’s timeline and not the cell. A user turn is closed when no user fragment follows on that timeline for turn_gap_ms, measured end_ms against start_ms and never against arrival time. Agent fragments up to the next user fragment belong to the open turn, and an agent start lying before the end of the last user fragment cuts nothing. An interjection during an answer hangs on the running turn as a backchannel as long as it stays under backchannel_max_ms; over it, it is a barge-in, the running turn closes, the next opens with the whole of it, and the open speaking stretch ends as cancelled. A turn with no assistant part passes.

speak_end comes out of quiet here and not out of the end of a synthesis, because there is no synthesis whose end could be awaited. A speaking stretch counts as open as soon as a comment has been answered, and it ends when spoken_quiet_ms pass without an assistant fragment or spoken_cap_ms is reached. That keeps the count balanced for whoever ordered speak_end, so a hangup still waits for the end of the sentence.

A live session spends context while it runs. Measured on 2026-09-19 it is 2.07 % of the 128k window per minute, which puts the 90 per cent mark at around forty minutes, and usage_ratio travels on every turn so that whoever listens sees it coming. The session reports its own standing every fifteen seconds; one measured session ran 305 seconds without a fault, and closing it took 766 milliseconds.

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: since voice@2.3.0 as four files, the page and its test.js, test.css and worklet.js beside it, with no inline script and no blob: worklet, so it runs behind a proxy that sets script-src 'self' (GH #867), and GET /<mount> without the slash answers 308 to the relative <mount>/. It 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.

One name is reserved and nothing implements it: the frame tool_call and the lane of the same name. A model that calls tools inside its own session needs a place to say what it wants called, and the reservation keeps that place free; the delegation to the backend does not fill it, because it carries no task text. No code stands behind the name today, and this paragraph promises no behaviour. The second reserved name, spoken, is built with 2.1.0.

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:

KeyTypeDefaultMeaning
mountstringrequired (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_modeauto | hold"auto"Mode for a connection that names none in its query
barge_inbooltrueSpeech starting while the cell speaks cancels the synthesis (auto only)
emit_partialsboolfalseEmit partial as a lane. Off by default: whoever listens orders it via override_params. The client’s mirror frame is unaffected
emit_speak_endboolfalseEmit 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_msu645000Operation timeout (rule 12) around every provider I/O the cell initiates
provider_idle_timeout_msu6430000Idle 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_msu3220How 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_plainbooltrueRewrite 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_msu642500How 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
sttobjectrequired unless duplex is setprovider plus that provider’s own fields
ttsobjectrequired unless stt.provider is "echo" or duplex is setprovider plus that provider’s own fields
duplexobject—provider plus that provider’s own fields. Excludes stt and tts

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.

duplex.gpt_live: api_key (secret), base_url, model (gpt-live-1), voice (marin), sample_rate (16000, otherwise 24000, null reads as not named; any other rate is refused, and the same one holds for both directions, because the session knows one format), instructions (required and not empty: who the model is for this session, and immutable while it runs), greeting (""), turn_gap_ms (1000), backchannel_max_ms (1500), spoken_quiet_ms (1500), spoken_cap_ms (8000), close_grace_ms (15000), tick_ms (1000), keepalive_ms (8000), delegation_grace_ms (12000), delegation_fallback ("Das kann ich gerade nicht nachsehen."), renew_after_ms (0, off: after this many milliseconds the cell opens a second provider session within the same call, before the first one reaches its limit, and reports it on renewed; a torn socket still ends the call, except with recover_max), renew_grace_ms (5000, positive: how long the renewed session waits for its handover), recover_max (0, off: how often per call the cell replaces a session the provider ended mid-call with a new one on a fresh seed; after that duplex_failed, GH #1377), audio_format (pcm16, else g711_ulaw or g711_alaw: per instance, never global; G.711 runs the model at 8 kHz on the wire, audio/pcmu or audio/pcma, while the client keeps sending and hearing L16 at 8000, because mod_audio_stream streams L16 only; the adapter compands every frame in both directions and never resamples; a G.711 block that also names sample_rate is refused at birth with audio_format_rate_conflict), seed_wait_ms (0, off: how long a session that opens waits for its seed on in_seed before it starts without one; 0 asks for none and sends the session.start it always sent), delta_min_chars and delta_min_ms (both 0, unthrottled: the partial lane is the caller’s speculative stream, cumulative and carrying the turn_id of the turn that closes it; with values, a new emission leaves on an incoming transcript delta only once the text has grown by that many characters AND that much time has passed since the last one, with no timer; the last state always leaves right before the turn), emit_spoken (not set: follows emit_partials; false turns off the spoken lane only, for a line that wants the caller’s deltas and has no edge for spoken). duplex.echo knows sample_rate (16000) and nothing else: it hands every frame back unchanged, answers every aside and mirrors it as assistant text, so the quiet heuristic can be checked without a model.

Usage and time marks of a duplex call (D48): emit_usage (false) turns on the usage lane, hop seconds (the call’s sum over every provider session, renewals included) and final; it goes out with final false at every meter reading of any session of the call and when a session the call renewed away from closes, and exactly once with final true at the end of the call, whatever ended it. Each session counts with what it closed with, or with its last meter reading where its close never arrived (a session still retiring at the hang-up, one let go by the next takeover, a renewal that ended before it took over, a session in charge whose verdict timed out); the final then carries complete: false, and complete: true when every session that reported a reading closed. A session that never reported one adds nothing, unless it was opened and lost without a Closed (a failed renewal after its Started, a session opened while the line rang that a connection at another format throws away): then complete: false. It is the one exception to OR-L22 (no session lane), because a call renewed at every fact turn spans many sessions and no single Closed carries its sum; the cell computes no price. The final belongs to the connection: when a new connection with the same session= displaces the old one, each carries its own final with the usage of its own sessions only, so two per call_id; a consumer sums per call_id. call_marks_s ([], seconds, strictly ascending and positive) and call_mark_every_s (0) turn on the call_mark lane with hop elapsed_s: first the list, then every call_mark_every_s seconds, counted from the moment the connection opened. One timer per call, the next armed only after the last fired; it lives in the connection’s loop and ends with the call, so a call that is over fires no mark. All three are settled at birth; without them the cell emits neither lane. The telephony hive freeswitch lets both out of its rim (#1369), and member declares them as ports at ./channels; the set draws a call’s final usage as in_call_end to the generation’s curator, which answers with call_facts (templates/curator/README.md).

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. duplex stands beside them and is immutable for the same reason: rotation means a respawn, which asks the vault again with a grant and reads .env without one, and tuning means a respawn. credential_backoff_max_ms is a known key and still not mutable, because the clocks of the I/O half read it once per life. emit_usage, call_marks_s and call_mark_every_s are settled at birth as well: every life carries them over from its birth params, and an update naming one is refused as immutable.

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.

browser, one browser per member

One browser per member, as a cell. It holds a Chromium-based browser out of the machine’s package system, a browser context per identity and a page per card, and puts each page’s picture on a topic of the socket a display already holds (page:<page>, GH #643’s second kind). What the topology hears about are pages: one opened, one moved, one went quiet, one came back. A frame never becomes a message — it travels on the link, and pointers, wheels, keys and text come back on the same link.

The browser is a prerequisite, not a delivery. meclaw ships no binary, no cache path, no AppArmor profile and no sandbox switch. params.chromium_path is required, has no default and no search path: a substrate that went looking would eventually run a binary nobody vetted. Take the full browser rather than the headless shell — the shell renders no PDF, and a page that is a PDF is a page a person will want to look at.

The browser’s confinement belongs to its package. This cell adds no sandbox flag, refuses --no-sandbox in extra_args, and measures after the spawn whether the confinement holds: it reads the processes below the pid it remembers — never a name pattern, because a machine runs other people’s browsers — and refuses with spawn_failed when not one of the children left this user namespace. What params.sandbox does here is the cell’s ceiling: a cgroup cap and nothing else (config.md § The cap alone). It is the one cell type where the block is required: everywhere else an absent sandbox means the historical, unenforced behaviour, and here that would be a process tree with a renderer per site and no cap at all. A restricted profile must carry limits, since one without a cap is trusted under another name; an operator who wants no ceiling writes {"trust": "trusted"} and has said so.

A context is an identity, and contexts are ephemeral on purpose: a browser restart is a logged-out browser, because a cell that persisted a session quietly would be holding a login nobody wrote down. A page is a view, and its id is the application’s card id — one word for page, topic suffix and prop. max_pages caps how many are open; above it the cell closes the oldest suspended one and answers too_many_pages when none is suspended. A page somebody might be looking at is never displaced quietly — and the displaced one is not quiet either: it emits page {state:"closed"}, so the application that owned its card hears about it and the row goes with it.

A page comes back out of suspended two ways, and both are the application doing something rather than the cell deciding: in_navigate, and a join on its topic. A join on a suspended page reopens it at the address its row remembers, in the context it was opened under, and starts the picture.

The viewport is applied and not only reported: the page is switched with Emulation.setDeviceMetricsOverride when it opens and again when the output that is being touched brings another shape, because a pointer’s coordinates are CSS pixels of the page viewport and a page rendered at another size puts every tap somewhere else.

Four verbs, in the tool_call shape store and harness also read: in_open {page, url, context?, viewport?}, in_navigate {page, url}, in_close {page}, in_context_close {context}. There is no fifth for an input: an input travels on the link the picture comes down, never as a message — a message per keystroke would run through the router, the log and the edge table.

Three lanes back, every one declared in this cell’s own contract.emits.hop.route.values: page on every state change (opening, active, background, suspended, reopened, throttled, closed), carrying the full address including its fragment, the title, the context, the viewport, content_type and — after somebody used it, at most once every 20 s of quiet — active_at as epoch milliseconds in text; error for spawn, CDP and crash failures; receipt as the answer to every message.

On the wire: cell to viewer one binary frame, event image, 16 bytes of head (u32 big-endian width, height, scroll x, scroll y, in CSS pixels) and then the JPEG. Viewer to cell text frames: pointer, touch, wheel, key, text and navigate — the last one only with about:back, about:forward or about:reload, because a free address is the application’s business (in_navigate). The picture runs while somebody is watching and stops when nobody is; the pace is the acknowledgement, and a viewer that stops reading is given up with 4408. The cell never waits for a viewer.

error_code (closed): invalid_input, unknown_page, too_many_pages, spawn_failed, startup_timeout, browser_crashed, navigate_failed, cdp_timeout, client_too_slow.

Emission mode: long-running (dual task), not lazy, with its own cell.db holding a pages table. cell.timeout: -1 and no message_timeout: every CDP round trip, the spawn, the wait between the cell’s two halves and a page: join all stand under params.external_timeout_ms, and a long-running cell has no backstop behind that. A verb whose answer runs out of time is a receipt with cdp_timeout naming the verb; a join that does is refused with 504. After a restart the cell reads its own pages table and opens what stood there (reopened) — nobody is asked, and there is no state an application would have to answer.