Skip to content

Authoring a programmable agent

A programmable agent is your code, in your language, driving a substrate session over protocol v1. For the concepts behind the channel, read programmable agents & the function plane first.

There are two ways in. The Python authoring kit is the ergonomic surface and where most authors should start. The raw protocol is what the kit is built on, what a peer in any other language implements, and what the rest of this page documents — because nothing here is hidden behind the kit.

The authoring kit: write once, run either way

Section titled “The authoring kit: write once, run either way”
import asyncio
from apomesh.agent import AgentSession, run
async def my_agent(session: AgentSession) -> str:
reply = await session.generate(
[{"role": "user", "content": session.goal.get("description", "")}]
)
await session.report_position({"step": 1})
return "GoalMet"
if __name__ == "__main__":
asyncio.run(run(my_agent, endpoint="localhost:9000", goal="summarise the incident"))

Run that file directly and it attaches as a tethered driver: your process holds the control flow on your machine and the platform relays its runtime channel — the fast authoring loop, with a debugger attached. Publish the same file as an image and it runs resident: confined beside a worker, over its own stdio.

Nothing in the agent changes between the two. run() picks the carriage from the environment — the platform sets APOMESH_RUNTIME_SESSION for a resident runtime and does not for a driver. That works because both carriages complete the same handshake and receive the same init, so the kit is ergonomics over one protocol rather than a compatibility layer over two.

One difference is deliberate and refused rather than hidden: a tethered session cannot be Replayable. Replay serves a step from the journal instead of re-executing it, which is sound only because the sandbox makes that journal complete — code on your own machine can perform effects the platform never sees. Asking for it at attach is a typed refusal, never a silent downgrade.

The rest of this page is the workload’s side of protocol v1 — what the kit does for you, and what a peer in another language implements itself.

These bite first, so they come first.

stdin and stdout are one socket, not two pipes. The platform hands the workload a single socketpair as fds 0 and 1. A stray print() corrupts the stream mid-frame, and the failure surfaces as a decode error blamed on the next frame. Send diagnostics to stderr.

No credential ever reaches the process. A generation runs worker-side against the session’s provider router; a tool’s secrets inject at the sandbox boundary. APOMESH_RUNTIME_SESSION is the only variable the substrate adds, and it is the non-secret session id you echo in the handshake. Reading the environment for an API key is not working around a gap — it is asking for something the platform is built never to hand you.

A restart is not visible as a restart. There is no “you were restarted” flag. You resume by reading init.restore, which is exactly why a durable agent reports its position rather than tracking one in memory.

Every frame is a 4-byte big-endian length prefix followed by a JSON body.

import struct, json
MAX_FRAME_BYTES = 32 * 1024 * 1024 # a length prefix can ask for gigabytes
def encode_frame(frame: dict) -> bytes:
body = json.dumps(frame).encode("utf-8")
if len(body) > MAX_FRAME_BYTES:
raise ValueError(f"frame body is {len(body)} bytes, over the limit")
return struct.pack(">I", len(body)) + body

Check the declared length before consulting your buffer, so an absurd prefix is rejected rather than used to size an allocation. The platform is not the only thing that can write to a socket.

The length prefix is pinned byte-exactly by the conformance fixture. Frame JSON is not — the host decodes JSON, which has many equivalent renderings of one value, so demanding byte-identical bodies would fail conforming implementations.

  1. hello — you announce the session id from APOMESH_RUNTIME_SESSION, plus resume_from_seq if you are continuing.
  2. init — the platform tells you who you are, your family set, your session_seed, and restore (your last snapshot, or absent on a fresh run).
  3. calls — you send call frames; the platform answers each with a result frame correlated by call_id.
  4. snapshot_state — you hand over opaque bytes the platform stores and gives back as init.restore. The substrate never decodes them.
  5. terminal — you report the outcome, or a failure class.

A call frame carries four things:

def call(call_id: str, call_seq: int, family: str, body: dict) -> dict:
...
  • family — one of the ten families, and it must be in the set your published contract declared. Both the worker and the orchestrator check.
  • call_seq — strictly monotone, and it names the call. Read the next section; this is the field that gets agents wrong.
  • call_id — correlates this reply on this attachment. Opaque to the substrate; a fresh one after a restart is correct and expected.

call_seq is the dedup handle. Re-sending a sequence names the same call, so a mutating effect the substrate already applied is answered from what it did rather than applied twice.

In the reference agent it is simply the step number. That is the shape to copy.

An agent that minted a fresh sequence on resume would spawn a second child and write a second memory entry — and the substrate would be right to let it, because a new sequence is a new call by definition. If you resume at step 6, step 6’s call carries sequence 6 again.

The corollary is why position reports exist: your snapshot is opaque to the platform, so nothing structurally stops you resuming at step 8 and numbering your next call 1. Under a stable era that collides with a real record — which the journal’s request witness catches and refuses, rather than answering wrongly.

ch = Channel.over_stdio()
ch.send(hello(announced_session(), resume_from_seq=0))
init = ch.receive()
state = restore_bytes(init) # None on a fresh run
step = decode_position(state) if state else 0
while step < len(PLAN):
result = ch.call(PLAN[step], call_seq=step) # sequence == step
if not result_is_ok(result):
ch.send(failed("Process")); return
step += 1
ch.send(snapshot_state(encode_position(step)))
ch.send(terminal(outcome))

The ordering that matters: report the position, then take the next expensive step. A Replayable session serves that generation from the journal on a restore, so the step happens and costs nothing.

If your agent may be replayed, do not observe the world directly:

  • Randomness — derive it from session_seed and the step’s call_seq. The seed arrives on init as hex text; parse it as text, not as a JSON number, or a value above 2⁵³ will silently round.
  • Coarse time — read the logical clock the platform stamps on results.
  • Anything else — a precise timestamp, a uuid, a hostname, an env read — go through the ObservedValue family. You compute it locally, the platform records it, and every later replay is served the record.

The OS clock stays legitimate for measurements that are not control flow.

A programmable shape publishes like any other agent, with a RuntimeContract alongside the manifest: the digest-pinned image, the families set, the durability level, the resources envelope, and the protocol_version your image speaks. Publishing seals it into an immutable (name, version); a start then carries only goal and budget, because the published entry is the shape.

A complete worked implementation lives at deploy/local/reference-agent/:

FileWhat it is
apomesh_protocol.pyProtocol v1 from the workload’s side — framing, handshake, frame vocabulary. No agent policy.
agent.pyEvery function family, a resumable position, a mid-run HITL pause.
DockerfileStdlib only, unprivileged, no ambient credentials.
image_proof.pyDrives the built image over real inherited fds.
tests/The codec against the protocol contract, and the whole conversation against a scripted host.
Terminal window
# The codec + the agent's conversation
cd deploy/local/reference-agent && uv run --with pytest pytest tests/ -q
# The image over real inherited fds (requires Docker)
python3 deploy/local/reference-agent/image_proof.py
# The full acceptance proof against a running stack (`just up` first)
python3 deploy/local/reference-agent/stack_e2e.py

crates/apomesh-substrate-types/tests/protocol-v1-golden.json is hand-authored and states what a conforming peer may put on the channel. Both the Rust types and the Python codec are checked against it, never against each other — the direction is one-way, so the Rust types own protocol v1 and everything else mirrors the fixture. Build your own peer against that file.