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.
Security model
Section titled “Security model”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.
- 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. X-Agent-Idis 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.- No general per-request signing or token auth.
/healthand/shutdownaccept any caller. A hostile local process can shut the daemon down; this is equivalent to sendingSIGTERM, 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. - 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.
- Shell execution is by design.
/execand 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.
Concepts
Section titled “Concepts”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.
Base URL and headers
Section titled “Base URL and headers”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": "..." }.
Endpoints
Section titled “Endpoints”Agents
Section titled “Agents”GET /agents — list agents
Section titled “GET /agents — list agents”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.
Local webhook
Section titled “Local 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" }
GET /events/stream — stream live events
Section titled “GET /events/stream — stream live events”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-streamInitial ready event:
event: readydata: {"agent_id":"default"}For each local webhook event:
event: eventdata: {"id":"evt_...","agentId":"default","receivedAt":"...","source":"local-webhook","text":"...","status":"pending"}Heartbeat:
event: pingdata: {"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.
Sessions (topics)
Section titled “Sessions (topics)”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" }
Exec (the hot path)
Section titled “Exec (the hot path)”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:nameform. Must parse successfully.request_id(optional): opaque client-chosen string. Echoed back in theheadevent and included as a prefix in thecontentbody (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-streamCache-Control: no-cacheConnection: keep-aliveX-Accel-Buffering: noBody: three SSE events in order.
Event 1: head
Section titled “Event 1: head”Metadata about the command result. Always sent first.
event: headdata: {"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.nullon 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’srequest_id, ornull.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, ornullif 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".
Event 2: content
Section titled “Event 2: content”The actual output of the command. Always sent second.
event: contentdata: "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] /cmdERROR(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.
Event 3: done
Section titled “Event 3: done”Signals stream completion. Always sent last.
event: donedata: {}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.
Queueing and contention
Section titled “Queueing and contention”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).
Other errors
Section titled “Other errors”400 { "error": "X-Agent-Id header required" }— missing header401 { "error": "Unknown agent: <id>" }— agent not registered400 { "error": "Invalid JSON body — expected { \"cmd\": \"...\" }" }— malformed JSON400 { "error": "Empty command — provide non-empty \"cmd\" field" }— missingcmd400 { "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/jsonfor POSTAccept: 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.
Lifecycle
Section titled “Lifecycle”GET /health — daemon health and stats
Section titled “GET /health — daemon health and stats”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).
Client implementation checklist
Section titled “Client implementation checklist”A conforming v0.1 client SHOULD:
- Ping the daemon via
GET /healthbefore the first/execcall. Auto-start the daemon if unreachable (out of scope for this document — each language has its own spawn mechanism). - Ensure the agent exists via
POST /agentsbefore the first/execcall. Registration is idempotent — calling it on every client startup is fine. - Accept
text/event-streamon/execrequests, parse the three SSE events, and return a structured result:{ ok: boolean, code: string | null, content: string, meta: object | null } - Strip the
re: [request_id] /cmd\nprefix from thecontentevent data before returning it to the caller. - Handle SSE parse failures gracefully — if
headarrives butcontentordonedoes not, return a synthetic{ ok: false, code: "STREAM_INCOMPLETE" }. - Surface 429/504 queue errors as distinct from generic failures so the caller can retry with backoff.
- NOT retry
/execcalls 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()andhealth()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
/execcalls without anX-Agent-Idheader. - Interpret the SSE
data:field as raw bytes — it is always a JSON-encoded value. - Assume the
metafield in theheadevent is non-null. - Assume commands are synchronous from the daemon’s perspective — always wait for the
doneevent.
Reference implementations
Section titled “Reference implementations”- TypeScript:
@string-os/client— 185 lines, uses only Node.js built-inhttp. - Python:
string-os— planned for v0.1.x.
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.
Changelog
Section titled “Changelog”- 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 fieldtopic_type(nottarget_type).