Skip to content

stringd Protocol v0.1

Status: Normative. This document specifies the wire protocol between a String client and a stringd daemon. Any client implementation (TypeScript, Python, Go, anything else) that conforms to this document can drive a running stringd. The reference implementations are @string-os/client (TypeScript) and string-os (Python, planned).

Version: 0.1 — matches @string-os/string@0.1.x. Breaking changes to this protocol will bump the major version of the daemon and be documented in stringd-protocol-v0.2.md, with explicit migration notes.

Transport: HTTP/1.1 over loopback (127.0.0.1) by default. Port: 3923 by default, configurable via STRING_PORT.


stringd is a single-agent, loopback-only daemon. The protocol is designed around these assumptions — conforming implementations MUST NOT loosen them without an explicit versioned extension.

  1. Loopback only. The daemon binds 127.0.0.1. It is not a network service. Do not put it behind a reverse proxy, do not expose it in a shared container, do not publish the port. CORS is permissive for local-tooling convenience, not as a security boundary.
  2. X-Agent-Id is identity, not authentication. It selects which agent record a request operates under. Any process on the loopback interface can set any value. The trust assumption is “same host, same OS user” — processes on the same machine running as the same UID are trusted to not lie to each other.
  3. No general per-request signing or token auth. /health and /shutdown accept any caller. A hostile local process can shut the daemon down; this is equivalent to sending SIGTERM, which such a process could already do. Local webhooks are the exception: their URL contains an agent-specific random token so unrelated local posts do not accidentally land in an agent inbox.
  4. Request body cap. The daemon rejects bodies larger than 10 MiB to prevent trivial OOM. Clients SHOULD NOT send JSON payloads anywhere near this bound.
  5. Shell execution is by design. /exec and CLI-method actions run under /bin/bash -c. This is a deliberate property of the agent runtime, not a vulnerability. Embedders who need sandboxing must provide it at the OS level (containers, firejail, VMs).

If your deployment needs multi-tenant access, remote use, or per-request authentication, do not run v0.1 of stringd. These are planned for v0.2 as an explicit protocol extension.


An agent is the top-level identity. Each agent has a home directory (agent.home) and an optional allowlist of additional accessible paths. Agents are registered via POST /agents and persist across daemon restarts in ${STRING_DATA_DIR}/agents.json (default: ~/.string/daemon/agents.json).

A topic is the unit of session scope. A topic is either a bare name — a free-form tab (e.g. main, docs) — or canonical: app:name[:config] (e.g. app:weather:korea) or bash:name (e.g. bash:dev). The bare names app, bash, tool, event, system, and agent are reserved as hub aggregators. A topic belongs to an agent. The runtime maintains per-topic state (current document, variables, history, bash PTY if applicable).

A command is a single /open, /act, /nav, /info, /set, /edit, etc. invocation. Commands execute in a topic’s context. A client sends one command per POST /exec call; the daemon responds with a Server-Sent Events (SSE) stream containing a head event, a content event, and a done event.


All endpoints are served under http://127.0.0.1:${STRING_PORT}. The daemon does not bind to external interfaces.

All requests that operate on agent data must include the agent ID header:

X-Agent-Id: <agent_id>

Endpoints that do not require an agent (/agents, /health, /shutdown) ignore this header if present.

CORS is permissive: the daemon sends Access-Control-Allow-Origin: * and Access-Control-Allow-Headers: Content-Type, X-Agent-Id on every response, and responds to OPTIONS preflight with 204 No Content. Clients should not rely on CORS for security — stringd is intended for loopback only.

All request bodies, where present, MUST be Content-Type: application/json, except POST /webhook/:token, which accepts text.

All error responses are JSON: { "error": "<human-readable reason>" } with an appropriate HTTP status. Some errors include a machine-readable code field: { "error": "QUEUE_FULL", "message": "..." }.


Response 200:

{
"agents": [
{ "id": "default", "home": "/home/alice/.string/agents/default", "allowedPaths": [], "createdAt": "2026-04-14T05:52:00.000Z" }
]
}

POST /agents — register or update an agent

Section titled “POST /agents — register or update an agent”

Request:

{ "agent_id": "default", "home": "/home/alice/.string/agents/default" }

agent_id is required. home is optional; when omitted, the daemon ensures the agent exists without clobbering a stored home. New agents without an explicit home get ~/.string/agents/{agent_id}. Registration is idempotent: if agent_id already exists, the call updates only fields provided by the request and preserves createdAt.

Response 200:

{ "agent_id": "default", "home": "/home/alice/.string/agents/default", "created": true }

created: true if the agent was newly registered, false if the call updated an existing agent.

Errors:

  • 400 { "error": "agent_id required" }

GET /agents/:id/webhook — show or create local webhook

Section titled “GET /agents/:id/webhook — show or create local webhook”

Creates an agent-specific local webhook token if one does not already exist, then returns the loopback URL.

Response 200:

{
"agent_id": "default",
"webhook_url": "http://127.0.0.1:3923/webhook/wh_..."
}

POST /agents/:id/webhook — rotate local webhook

Section titled “POST /agents/:id/webhook — rotate local webhook”

Rotates the agent’s webhook token. The old URL stops working immediately.

Response 200: same shape as GET /agents/:id/webhook.

POST /webhook/:token — append event text

Section titled “POST /webhook/:token — append event text”

Accepts a non-empty text body up to 64 KiB and appends it to the target agent’s event inbox. The payload is stored as text. It is not executed.

Response 202:

{ "ok": true, "agent_id": "default", "event_id": "evt_..." }

Errors:

  • 401 { "error": "Unknown webhook token" }
  • 400 { "error": "Webhook body must be non-empty text" }
  • 413 { "error": "Webhook body exceeds 65536 bytes" }

Subscribes to newly appended events for one agent. This endpoint is used by SDK agents and by string --mcp when it forwards local webhook events to Claude Code as channel notifications.

Headers:

  • X-Agent-Id: <agent_id> (required)
  • Accept: text/event-stream (recommended)

Response 200:

Content-Type: text/event-stream

Initial ready event:

event: ready
data: {"agent_id":"default"}

For each local webhook event:

event: event
data: {"id":"evt_...","agentId":"default","receivedAt":"...","source":"local-webhook","text":"...","status":"pending"}

Heartbeat:

event: ping
data: {"t":"..."}

Only events appended after the stream opens are delivered. Durable reads and acknowledgement still go through the event topic (/events, /events.read <id>, /events.ack <id>).

Errors:

  • 400 { "error": "X-Agent-Id header required" }
  • 401 { "error": "Unknown agent: <id>" }

DELETE /agents/:agent_id — delete an agent

Section titled “DELETE /agents/:agent_id — delete an agent”

Removes the agent record and any in-memory runtime state (sessions, bash PTYs). Does not delete files in the agent’s home directory.

Response 200:

{ "agent_id": "default", "deleted": true }

deleted: false if the agent did not exist.


GET /sessions?agent_id=<id> — list active sessions

Section titled “GET /sessions?agent_id=<id> — list active sessions”

The agent_id query parameter is optional. If omitted, returns sessions across all agents.

Response 200:

{
"sessions": [
{
"agent_id": "default",
"topic": "main",
"topic_type": "tab",
"executing": false,
"queue_length": 0,
"doc": {
"uri": "file:///home/alice/work/index.md",
"title": "Home",
"current_block": null
}
}
]
}

The doc field is null if no document has been opened in the session yet.

POST /sessions — create or touch a session

Section titled “POST /sessions — create or touch a session”

Ensures a session exists for the given agent+topic. Does not execute any command — the session starts empty and waits for the first /exec call.

Request:

{ "agent_id": "default", "topic": "main" }

Response 200:

{ "agent_id": "default", "topic": "main", "topic_type": "tab", "created": true }

Errors:

  • 400 { "error": "agent_id required" }
  • 401 { "error": "Unknown agent: <id>" }
  • 400 { "error": "Invalid topic: <raw>" }

DELETE /sessions/:agent_id/:topic — close a session

Section titled “DELETE /sessions/:agent_id/:topic — close a session”

Releases the topic’s runtime state and closes any bash PTY.

Response 200:

{ "agent_id": "default", "topic": "main", "deleted": true }

deleted: false if the session did not exist.

Errors:

  • 400 { "error": "Expected /sessions/:agent_id/:topic" }

POST /exec — execute one command in a topic

Section titled “POST /exec — execute one command in a topic”

This is the primary endpoint. Clients send a command; the daemon returns an SSE stream with the result.

Headers:

  • X-Agent-Id: <agent_id> (required)
  • Content-Type: application/json (required)
  • Accept: text/event-stream (recommended; the daemon emits SSE regardless)

Request body:

{
"cmd": "/open ./README.md",
"topic": "main",
"request_id": "optional-client-request-id"
}
  • cmd (required, non-empty): the command line. Must start with /.
  • topic (required): type:name form. Must parse successfully.
  • request_id (optional): opaque client-chosen string. Echoed back in the head event and included as a prefix in the content body (re: [request_id] /cmd\n...). Use this to correlate requests when multiple are in flight.

Response (success path): HTTP 200 with headers:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no

Body: three SSE events in order.

Metadata about the command result. Always sent first.

event: head
data: {"ok":true,"code":null,"cmd":"/open ./README.md","request_id":null,"agent_id":"default","topic": "main","topic_type": "tab","meta":{"uri":"file:///home/alice/work/README.md","title":"README","current_block":null}}

Fields:

  • ok (boolean): whether the command succeeded.
  • code (string | null): command result code. null on success, a short error code like "NOT_FOUND" or "INTERNAL_ERROR" on failure.
  • cmd (string): the command as received, possibly truncated for display. Do not use this for re-execution.
  • request_id (string | null): echo of the client’s request_id, or null.
  • agent_id (string): the agent the command ran as.
  • topic (string): the topic, canonicalized.
  • topic_type (string): one of "tab", "app", "bash", "hub".
  • meta (object | null): current document metadata after the command, or null if no document is loaded.
    • uri (string): absolute URI of the current document.
    • title (string | null): document title from frontmatter.
    • current_block (string | null): current block anchor, e.g. "#welcome".

The actual output of the command. Always sent second.

event: content
data: "re: /open ./README.md\n# Hello\nContent of the document..."

The data field is a JSON-encoded string (note the quotes — SSE data: lines are JSON, not raw).

Content body format:

re: [optional-request-id] /cmd
<body>

Or on error:

re: [optional-request-id] /cmd
ERROR(CODE): <error message>

Clients should strip the first line (re: ...) to get the raw output. The reference @string-os/client exposes a helper stripContentPrefix(raw) for this.

Signals stream completion. Always sent last.

event: done
data: {}

After done, the daemon calls res.end() and closes the stream. Clients should treat the absence of done (e.g. connection drop) as an incomplete result.

A topic processes one command at a time. If a second command arrives while the first is executing, it is queued (up to MAX_QUEUE_SIZE = 5). If the queue is full, the new request returns immediately:

Error 429:

{ "error": "QUEUE_FULL", "message": "Topic default:main has 5 commands queued. Try again later." }

If a queued request waits longer than QUEUE_WAIT_TIMEOUT_MS = 120000 (120 seconds), it times out:

Error 504:

{ "error": "QUEUE_TIMEOUT", "message": "Timed out waiting in queue." }

If the client disconnects while waiting in the queue, the request is aborted silently (no response).

  • 400 { "error": "X-Agent-Id header required" } — missing header
  • 401 { "error": "Unknown agent: <id>" } — agent not registered
  • 400 { "error": "Invalid JSON body — expected { \"cmd\": \"...\" }" } — malformed JSON
  • 400 { "error": "Empty command — provide non-empty \"cmd\" field" } — missing cmd
  • 400 { "error": "Invalid topic: <raw>" } — topic did not parse

POST /mcp · GET /mcp · DELETE /mcp — Model Context Protocol endpoint

Section titled “POST /mcp · GET /mcp · DELETE /mcp — Model Context Protocol endpoint”

Serves the Model Context Protocol over the Streamable HTTP transport. One tool — string({ topic, cmd }) — wraps the entire command surface (same as /exec).

Headers:

  • X-Agent-Id: <agent_id> (optional, defaults to "default")
  • Content-Type: application/json for POST
  • Accept: application/json, text/event-stream (recommended)

Body: JSON-RPC 2.0 messages (initialize, tools/list, tools/call, etc.). See the MCP spec for the wire format.

Tool output (tools/call for string):

{
"content": [{ "type": "text", "text": "<𝒞=string:TOPIC>\n<body>\n</𝒞>" }],
"isError": false
}

The body is wrapped in a ChanFlow envelope identical to the CLI’s stdout — the topic lives in the opening tag, so no separate structuredContent field is needed. On error, isError is true and the body carries the code as ERROR(CODE): <message> (e.g. ERROR(NOT_FOUND): …).

Unknown agents are auto-registered on first touch — no POST /agents needed. Home is derived as ~/.string/agents/{agent_id}. This is unique to /mcp (the /exec endpoint still requires explicit registration via POST /agents).

Stateless mode: a fresh server+transport per request, no session IDs. Concurrent calls on the same topic from a single agent are serialized with a BUSY rejection (no queue at v0.1).

See Runtime → MCP for client setup examples.


Response 200:

{ "ok": true, "agents": 1, "sessions": 3 }

No auth required. Clients use this as a liveness check before sending a command. sessions counts active topics across all agents.

POST /shutdown — request daemon shutdown

Section titled “POST /shutdown — request daemon shutdown”

Sends a final JSON response, then exits the process after a 50 ms grace period.

Response 200:

{ "ok": true, "message": "stringd shutting down" }

No auth required. Clients are responsible for deciding whether shutdown is appropriate (stringd is typically agent-scoped, but a hostile client on loopback can call this).


A conforming v0.1 client SHOULD:

  1. Ping the daemon via GET /health before the first /exec call. Auto-start the daemon if unreachable (out of scope for this document — each language has its own spawn mechanism).
  2. Ensure the agent exists via POST /agents before the first /exec call. Registration is idempotent — calling it on every client startup is fine.
  3. Accept text/event-stream on /exec requests, parse the three SSE events, and return a structured result:
    { ok: boolean, code: string | null, content: string, meta: object | null }
  4. Strip the re: [request_id] /cmd\n prefix from the content event data before returning it to the caller.
  5. Handle SSE parse failures gracefully — if head arrives but content or done does not, return a synthetic { ok: false, code: "STREAM_INCOMPLETE" }.
  6. Surface 429/504 queue errors as distinct from generic failures so the caller can retry with backoff.
  7. NOT retry /exec calls automatically — commands can have side effects. Retries are the caller’s decision.

A conforming client MAY:

  • Cache the daemon port after the first ping.
  • Expose shutdown() and health() as public methods.
  • Spawn a daemon subprocess when ping() fails on loopback.
  • Reuse an HTTP keep-alive connection across commands (the daemon supports it).

A conforming client MUST NOT:

  • Send /exec calls without an X-Agent-Id header.
  • Interpret the SSE data: field as raw bytes — it is always a JSON-encoded value.
  • Assume the meta field in the head event is non-null.
  • Assume commands are synchronous from the daemon’s perspective — always wait for the done event.

If you implement this protocol in another language, open an issue at https://github.com/string-os/string/issues with a link to your implementation and we will list it here.


  • v0.1.0 (2026-04-14): initial release. Endpoints: /agents (GET/POST/DELETE), /sessions (GET/POST/DELETE), /exec (POST), /health (GET), /shutdown (POST). SSE event triple: head/content/done. Wire field topic_type (not target_type).