Handler Protocol

StatusDraft, stable from v0.1.0
Version1

This document specifies the inter-process protocol between a pugmark runtime (the process driving a session — sequencing handler invocations, committing snapshots) and a handler (the subprocess or HTTP service that produces new log entries given an existing session log).

The spec is the authoritative source. Any conformant implementation, in any language, must satisfy the rules below; behavioural differences between an implementation and this document are bugs in the implementation.

The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in RFC 2119.

The on-disk storage layout the runtime persists to (and from which it reconstructs the session log on every invocation) is defined in a companion document, storage.md.


Table of Contents

  1. Overview
  2. Transports
  3. Wire format
  4. Input records
  5. Output records
  6. Events
  7. Object data fetch
  8. Memory namespaces
  9. Control messages
  10. Pause and sleep
  11. Forking
  12. Cross-session writes
  13. Environment
  14. Distributed tracing
  15. Named handlers (routing)
  16. Termination
  17. Stability and versioning

1. Overview

A pugmark session is an append-only log of objects persisted in object storage. A handler is a process that, given the session log up to some snapshot version v, produces zero or more new objects to be appended at snapshot v+1.

On every invocation, the runtime:

  1. Materialises the session’s full log as an ordered list of objects (parent-chain objects walked through manifests, followed by the current session’s snapshots in version order).
  2. Starts two ephemeral HTTP servers, one serving object bodies and one serving memory-namespace bodies. The handler reaches both at URLs the runtime hands it on stdin (§7, §8).
  3. Sends one Input record per log entry on the handler’s input stream, in stream order, followed by exactly one lifecycle event record.
  4. Reads zero or more Output records from the handler’s output stream until the handler closes it (subprocess exits, HTTP response ends).
  5. Commits the outputs as a new snapshot of the session, observing the snapshot-type and conflict rules in storage.md.

The handler operates against a specific snapshot version; the runtime never feeds the handler partial state. Handlers MUST be tolerant of being invoked on the same snapshot more than once — the runtime may retry on commit conflict, and at-least-once notification delivery in cloud transports may cause duplicate invocations. Handlers SHOULD be deterministic given identical input; any non-determinism (LLM responses, timestamps) is implicitly checkpointed by the very act of appending its output to the log.

2. Transports

A handler communicates with the runtime over one of two transports. Both share an identical wire format above the transport layer.

2.1 Subprocess

The runtime spawns the handler as a child process. Input records arrive on the handler’s standard input (file descriptor 0). Output records leave on the handler’s standard output (file descriptor 1). The handler signals completion by exiting. Standard error (file descriptor 2) is not part of the protocol; handlers SHOULD use it for human-readable logs and diagnostics. A non-zero exit status MUST be treated by the runtime as a failed invocation; the snapshot is not advanced.

2.2 HTTP RPC

The runtime sends a single POST request to a URL the handler exposes. The request body is the input stream; the response body is the output stream. The handler signals completion by ending the response body with HTTP status 200. Any other status is a failed invocation.

The request MUST include three query parameters:

ParameterTypeMeaning
agentstringThe agent identity for this invocation (§13).
session32-hex stringThe session ID.
snapshotdecimal integerThe snapshot version the handler is operating on.

The request MUST carry session metadata as an X-Pugmark-Session-Metadata header whose value is a JSON object mapping string keys to string values. The header is omitted if the manifest has no metadata.

The Content-Type of the request body MUST be application/json. The Content-Length header MUST be set; chunked transfer encoding MUST NOT be used by the runtime (some HTTP servers — notably Python’s wsgiref — do not decode chunked encoding correctly, causing reads to block).

3. Wire format

The input and output streams are sequences of JSON objects encoded as UTF-8. Each object is one Input record or one Output record; objects appear back-to-back, separated by any amount of JSON whitespace (space, tab, LF, CR).

Implementations:

A record where every recognised field is empty (or omitted) is a no-op and MUST be tolerated by parsers.

3.1 Binary data

Binary bodies carried inside the data field of Input or Output records are encoded as base64 strings using the standard alphabet (RFC 4648 §4). Encoders MUST NOT use the URL-safe alphabet and MUST NOT insert line breaks within the encoded payload. Decoders MUST accept both padded and unpadded encodings.

3.2 Empty fields

The schemas in §4 and §5 list every field a record MAY carry. Every field is optional at the wire level — encoders SHOULD omit fields that are empty (zero-length string, zero-length object, zero integer, false boolean), and decoders MUST treat a missing field as equivalent to its zero value.

4. Input records

An Input record describes one log entry the handler should consider, or one lifecycle event. Schema:

{
  "event":            "<event-type>",
  "url":              "<http URL>",
  "size":             <int64>,
  "content-type":     "<MIME>",
  "content-encoding": "<encoding>",
  "schema-url":       "<URL>",
  "name":             "<descriptor name>",
  "metadata":         { "<k>": "<v>", ... },
  "data":             "<base64>"
}

4.1 Field semantics

FieldTypeSemantics
eventstringOne of push, start, fork, wake, memory, or empty. See §6.
urlstringHTTP URL where the body can be fetched (§7). Absent for records that have no body (lifecycle events).
sizeintegerSize of the body in bytes. Informational; clients SHOULD NOT enforce equality with the fetched body length.
content-typestringMIME type of the body. Empty for lifecycle events.
content-encodingstringIf set, the body has been pre-encoded; one of gzip or zstd. The HTTP server returns the body with this same Content-Encoding set, and conforming HTTP clients will decompress automatically (§7).
schema-urlstringOptional schema URL associated with the body (e.g. a fully-qualified protobuf message type).
namestringThe descriptor name. Conventionally a filename or a typed-event name. MAY be empty.
metadataobject<string,string>Arbitrary string-keyed metadata carried by the descriptor.
database64 stringInline body, base64-encoded (§3.1). Present only for descriptors that carry inline data (see §4.3). If present, url MUST be ignored by the handler.

4.2 Stream order

On every invocation, the runtime emits Input records in this order:

  1. Memory namespace announcement — exactly one Input with event = "memory" and url pointing at the root of the memory HTTP server (URL ends with /). This MUST be the first Input on the stream. See §8.
  2. Log replay — zero or more Inputs, one per object in the session’s log, in stream order. The runtime MUST preserve the order in which objects were committed (parent-chain objects first, oldest first; then the current session’s snapshots in version order; within a snapshot, descriptor order). The runtime MUST filter out:
    • Control messages (§9).
    • Pending fork records from the current session’s most-recent snapshot (§11.2).
  3. Lifecycle event — exactly one Input with event set to one of wake, fork, start, or empty, and no other fields populated (see §6). This MUST be the last Input on the stream.

The handler MUST be tolerant of zero log-replay records (an empty session) and MUST treat the lifecycle event as the signal to begin processing.

4.3 The event field on replayed log entries

When replaying log entries, the runtime tags some of them with event = "push" to identify which entries are newly arrived relative to a hypothetical previous handler invocation on this session. The exact rule:

Handlers MAY ignore the event tag on replayed entries entirely and treat the log uniformly as ordered events. The tag is provided as a convenience for handlers that want to react only to deltas.

5. Output records

An Output record describes one entry the handler is appending to the log, or a terminal signal (pause/sleep). Schema:

{
  "uri":              "<storage URI>",
  "data":             "<base64>",
  "content-type":     "<MIME>",
  "content-encoding": "<encoding>",
  "schema-url":       "<URL>",
  "name":             "<descriptor name>",
  "metadata":         { "<k>": "<v>", ... },
  "fork":             <bool>,
  "session":          "<32-hex>"
}

5.1 Field semantics

FieldTypeSemantics
uristringReference to an existing object at a remote storage URI (for example, s3://bucket/path/to/object). When set, the runtime fetches the body from that URI, computes its SHA-256 digest, and stores it under the content-addressable layout in storage.md. Mutually exclusive with data. If both are set, uri is ignored.
database64 stringInline body (§3.1). Mutually exclusive with uri.
content-typestringMIME type of the body. MUST be set unless the entry is a pause signal (§10), which carries content-type = "application/vnd.pugmark.pause+text".
content-encodingstringIf set, one of gzip or zstd. The runtime stores the body as-is, with this encoding in effect on subsequent reads.
schema-urlstringOptional schema URL stored alongside the body.
namestringDescriptor name. If non-empty and prefixed memories/<namespace>/, the entry is routed to the memory namespace (§8).
metadataobject<string,string>Arbitrary string-keyed metadata stored on the descriptor.
forkboolIf true, this entry creates a child session (§11).
sessionstringIf set, the 32-hex session ID this entry targets. When equal to the current session, the entry is committed locally. When different, the entry is a cross-session write (§12). When combined with fork = true, the new child session is created with this ID; otherwise the runtime generates a random session ID.

5.2 Order

Output records MAY appear in any order. The runtime processes them in stream order and assigns descriptor sequence numbers in that order (sequence numbers are part of the storage layer; see storage.md).

5.3 Output processing

For each Output record, the runtime:

  1. Resolves the body — from data if present, otherwise by fetching uri.
  2. Stores the body under the content-addressable layout (one canonical storage key per unique body; see storage.md).
  3. Routes the resulting descriptor:
    • If content-type is application/vnd.pugmark.pause+text → treated as a pause signal (§10).
    • Else if name starts with memories/<namespace>/ → routed to the memory namespace (§8).
    • Else if fork = true → recorded as a pending fork (§11).
    • Else if session is set and differs from the current session → recorded as a cross-session write (§12).
    • Else → appended to the current snapshot as a normal log entry.

6. Events

Lifecycle events are signalled by the event field on Input records. The runtime emits exactly one lifecycle-event Input per invocation (in addition to the leading memory Input and the log-replay Inputs).

EventMeaning
startTrailing Input on the first invocation of a root (parentless) session whose snapshot version is 0.
forkTrailing Input on the first invocation of a child session (one whose manifest declares a non-zero parent) whose snapshot version is 0.
wakeTrailing Input on an invocation that resumes a previously paused session because new objects appeared after the pause control message.
pushAnnotation on replayed log Inputs that are newly arrived relative to the previous invocation (§4.3).
memoryThe leading Input of every invocation, announcing the memory namespace HTTP server (§8).
(empty)Replayed log Inputs that predate the latest “newly arrived” cohort, and the trailing lifecycle Input when none of start/fork/wake apply (steady-state in-flight execution).

Handlers MAY ignore lifecycle events entirely. The replay-decide-append pattern needs no lifecycle awareness — the log itself fully determines the next action.

7. Object data fetch

When an Input record carries url (and no inline data), the handler retrieves the body via HTTP GET against that URL. The runtime exposes two HTTP servers per invocation:

Both servers:

Handlers:

7.1 Inline data

When a log entry’s body is small enough that the runtime chose to inline it (the storage layer permits inlining bodies whose length is at most 1024 bytes), the Input record carries the body in data (base64-encoded). The Input MAY also carry url; handlers MUST prefer data when both are present. The two are guaranteed to refer to identical bytes.

8. Memory namespaces

Memory namespaces are versioned key-value stores that live outside any single session. They are intended for cross-session shared state — long-term agent memory, configuration that survives across runs, or shared knowledge bases.

8.1 Discovery

The first Input record sent to the handler on every invocation is a memory event whose url points at the root of the memory HTTP server:

{"event": "memory", "url": "http://<host>:<port>/memories/"}

The URL MUST end with a trailing /. Handlers SHOULD record this URL as the base for memory operations during the current invocation. Implementations MAY suppress this event from handler-visible event streams (the user-level event API exposes only start, fork, wake, and push).

8.2 Naming

A memory key is a path of the form <namespace>/<object-name>. The <namespace> segment MAY itself contain / characters (allowing hierarchical namespacing). The <object-name> segment MUST NOT contain /.

Implementations resolving a key to a namespace MUST use the longest-prefix match: given a set of mounted namespaces and a request key K, the resolved namespace is the longest mounted namespace N such that K starts with N + "/". The remainder K[len(N)+1:] is the object name.

8.3 Reading a memory object

The handler reads memory objects by performing HTTP GET against <memory-server-root> + <namespace> + "/" + <object-name>. The response is identical in shape to object-server responses (§7).

8.4 Writing a memory object

The handler writes a memory object by emitting an Output record whose name is memories/<namespace>/<object-name>. The runtime routes such Outputs to the memory namespace rather than to the session log.

Properties:

9. Control messages

A control message is an object whose content-type is application/vnd.pugmark.control+json. Its body is a JSON object:

{
  "status":   "running" | "stopped" | "unknown",
  "reason":   "<freeform string>",
  "start-at": "<RFC 3339 timestamp>"
}

All three fields are optional; missing fields take their zero values.

Control messages are produced by the runtime, not by handlers. They are appended to the log at commit time to record the run’s outcome:

Control messages are filtered out of the input stream presented to handlers — handlers never see them.

Handlers MUST NOT emit Output records with content-type = "application/vnd.pugmark.control+json".

10. Pause and sleep

A handler signals that it has no more work to do — or that it wishes to sleep for a bounded duration — by emitting an Output record with:

The runtime, on observing a pause output:

  1. Captures the reason and the optional duration; the duration is capped at 10 minutes — values exceeding 10 minutes are clamped down.
  2. Continues processing any further Output records the handler emits (multiple outputs after a pause are still committed).
  3. At commit time, appends a control message (§9) with status = "stopped", the captured reason, and (for sleep) start-at = (commit time) + (capped duration).

The next invocation of the same session sees the control message during log replay (the runtime filters it from the handler-visible stream, but uses it internally to decide what to do):

Handlers MAY emit multiple pause outputs in one stream, but SHOULD NOT — only the last is recorded.

A handler whose only outputs are fork outputs (§11) — no local writes, no pause, no cross-session writes — is treated as an implicit pause: the runtime MUST commit a control message with status = "stopped" automatically. This prevents an infinite re-invocation loop while the parent waits on its children. Cross-session writes (§12) do not trigger implicit pause; a fire-and-forget producer keeps running.

11. Forking

A handler creates a child session by emitting an Output record with fork = true. The body of the fork output becomes the first object in the child session’s snapshot 1.

The child session’s ID is:

11.1 Two-phase commit

Fork creation is two-phase and crash-recoverable:

  1. Record the fork. When the parent’s invocation commits, the fork Output appears in the parent’s new snapshot as an entry with fork = true and the chosen target-session = <child-id> (the pending fork). The body has already been stored content-addressably. The child manifest does not yet exist.
  2. Materialise the child. On the next invocation of the parent session, before sending Inputs to the handler, the runtime processes any pending forks from the parent’s most-recent snapshot. For each:
    • Create the child manifest with parent pointer set to the parent’s snapshot, and inherit the parent manifest’s handler and agent fields and the fork descriptor’s metadata. Manifest creation is atomic and conflict-tolerant: if the child manifest already exists, the runtime loads it rather than failing.
    • If the child’s snapshot version is already > 0, skip (the fork was already materialised by an earlier invocation).
    • Otherwise, commit a snapshot 1 to the child containing the fork’s body as a normal entry (with fork = false, no target-session). Commit conflicts on this write are treated as success — they mean another concurrent run already did it.
  3. Filter the pending forks out of the input stream sent to the parent’s handler for this invocation, so the handler does not re-see its own fork commands as live work.

The two-phase design is crash-recoverable: at any point between phase 1 and phase 2 the next invocation completes the materialisation; conflict-loss on either write is harmless because the surviving result is the desired outcome.

11.2 Handler-side observation

A child session’s first invocation sees event = "fork" on the trailing lifecycle Input and event = "push" on the single replayed Input that carries the fork body. The child’s parent-chain log walk traverses through its manifest’s parent pointer, so the child sees all objects accessible from its parent’s snapshot, up to and including the fork point.

Subsequent invocations of the child see event = "wake" or empty, just like any other session.

When the parent invocation runs after a fork, fork descriptors in the parent’s snapshot that target other sessions (siblings of any in-progress fork chain — i.e. forks inherited from the parent’s own ancestors, addressed to sibling sessions) MUST be filtered out of the parent handler’s input stream as well, so no session sees the fork records of unrelated siblings.

12. Cross-session writes

A handler MAY write to a different existing session by emitting an Output with session set to the target session ID (32-hex) and fork = false. The body is stored once (content-addressed); a descriptor referencing it is committed to the target session as a provisional snapshot (see storage.md for snapshot types).

The descriptor written into the target carries source-tracking fields:

This (source-session, source-version) pair is the idempotency key: before writing to the target, the runtime checks whether the target’s snapshot already contains a descriptor with the same source pair, and if so skips the write. This makes retries safe.

Cross-session writes do not trigger implicit pause on the writer (§10). A handler may continue running and emit further outputs after a cross-session write.

If session is set to the writer’s own session ID, the runtime MUST treat the entry as a normal local write (the session field is dropped at commit time).

13. Environment

The runtime, in the subprocess transport (§2.1), sets the following variables in the child’s environment:

VariableValue
PUGMARK_AGENT_IDThe session’s agent identity (from the manifest; default "main").
PUGMARK_SESSION_IDThe 32-hex session ID for this invocation.
PUGMARK_SNAPSHOT_VERSIONThe snapshot version (decimal integer) the handler is operating on.
PUGMARK_SESSION_METADATAA JSON object string of the session’s metadata. Omitted when the manifest has no metadata.
TRACEPARENT, TRACESTATEW3C trace context (§14). Set only when the runtime has an active trace.

For RPC mode (the subprocess hosts an HTTP service that the runtime calls — see §15), the runtime also sets:

VariableValue
PUGMARK_RPC_URLA URL the subprocess MUST listen on. Two schemes are accepted: fd://<N> (use inherited file descriptor N as a pre-created listening socket; Unix only); http://<host>:<port>/ (bind a new TCP listener at that address).

In the HTTP RPC transport (§2.2), the same identity and metadata travel as query parameters and HTTP headers respectively — see §2.2.

Implementations MUST make the values of PUGMARK_AGENT_ID, PUGMARK_SESSION_ID, and PUGMARK_SESSION_METADATA available to handler code as the agent identity, session ID, and session metadata for the current invocation, regardless of which transport is in use. The exact mechanism — environment variable, constructor argument, ambient context — is an implementation detail of the SDK in each language.

14. Distributed tracing

The runtime propagates a W3C trace context to the handler. The mechanism:

Handlers SHOULD consume the propagated context and attach it to spans they emit. HTTP fetches the handler issues against the object server (§7) and memory server (§8) SHOULD also carry the trace context onward, so the runtime’s spans for storage reads become children of the handler’s invocation span.

14.1 Session ID as trace ID

The 16-byte session ID MAY be reused as the OpenTelemetry trace ID for spans associated with the session — both are 16-byte identifiers and the wire representations are compatible. A conformant runtime MUST use the session ID as the trace ID for the root span of the invocation. This guarantees that every span emitted in the lifecycle of a session shares one trace ID, making cross-component correlation straightforward.

15. Named handlers (routing)

A session’s manifest MAY carry a handler field (a free-form string). When set, this field selects among multiple handler commands registered at the runtime level. A typical CLI invocation registers named handlers like:

pugmark run --handler python='python agent.py' --handler bash='bash worker.sh' --start

When the session’s manifest declares handler = "python", the runtime invokes the python command; when "bash", the bash command. When the manifest has no handler field set, the runtime falls back to a single default handler command supplied at invocation time.

Child sessions created via fork (§11) MUST inherit the parent’s handler and agent fields at materialisation time, so a fork tree consistently routes to the same handler.

16. Termination

The handler MUST close its output stream when it has produced all its outputs:

The runtime closes the input stream when it has finished sending Inputs. Handlers MAY ignore that signal and exit on their own schedule; they MUST NOT rely on input-stream-close as a hard “no more inputs” signal in the sense of blocking — read attempts may return EOF or 0 bytes either before or after all expected Inputs have arrived, depending on timing.

A handler that exits without producing any Outputs is a valid no-op invocation; the session is unchanged.

A non-zero exit status (subprocess) or a non-200 HTTP response (RPC) MUST be treated by the runtime as a failed invocation; the snapshot is not advanced.

17. Stability and versioning

This protocol is version 1. Compatibility rules:

If a future version of pugmark needs to break the protocol in a non-backward-compatible way, the wire-level signal will be a leading Input with event = "protocol" and a metadata.version field carrying the new version number. Handlers that see such an Input and don’t recognise the version MUST terminate without producing any Outputs.