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: 3100 by default, configurable via STRINGD_PORT.


stringd is a single-user, 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-User-Id is identity, not authentication. It selects which user 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 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.
  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.


A user is the top-level identity. Each user has a home directory (user.home) and an optional allowlist of additional accessible paths. Users are registered via POST /users and persist across daemon restarts in ${STRINGD_DATA_DIR}/users.json (default: .stringd/users.json in the daemon’s working directory).

A topic is the unit of session scope. A topic has the form type:name where type is one of file, app, web, or bash. Optional third segment for app config: app:weather:korea. A topic belongs to a user. 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:${STRINGD_PORT}. The daemon does not bind to external interfaces.

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

X-User-Id: <user_id>

Endpoints that do not require a user (/users, /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-User-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.

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:

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

Request:

{ "id": "default", "home": "/home/alice/.string/users/default" }

Both fields are required. home must be an absolute path. Registration is idempotent: if id already exists, the call updates home and preserves createdAt.

Response 200:

{ "user_id": "default", "home": "/home/alice/.string/users/default", "created": true }

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

Errors:

  • 400 { "error": "id required" }
  • 400 { "error": "home required" }

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

Response 200:

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

deleted: false if the user did not exist.


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

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

The user_id query parameter is optional. If omitted, returns sessions across all users.

Response 200:

{
"sessions": [
{
"user_id": "default",
"topic": "file:main",
"topic_type": "file",
"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 user+topic. Does not execute any command — the session starts empty and waits for the first /exec call.

Request:

{ "user_id": "default", "topic": "file:main" }

Response 200:

{ "user_id": "default", "topic": "file:main", "topic_type": "file", "created": true }

Errors:

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

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

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

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

Response 200:

{ "user_id": "default", "topic": "file:main", "deleted": true }

deleted: false if the session did not exist.

Errors:

  • 400 { "error": "Expected /sessions/:user_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-User-Id: <user_id> (required)
  • Content-Type: application/json (required)
  • Accept: text/event-stream (recommended; the daemon emits SSE regardless)

Request body:

{
"cmd": "/open ./README.md",
"topic": "file: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,"user_id":"default","topic":"file:main","topic_type":"file","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.
  • user_id (string): the user the command ran as.
  • topic (string): the topic, canonicalized.
  • topic_type (string): one of "file", "app", "web", "bash".
  • 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 = 16). If the queue is full, the new request returns immediately:

Error 429:

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

If a queued request waits longer than QUEUE_WAIT_TIMEOUT_MS = 60000 (1 minute), 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-User-Id header required" } — missing header
  • 401 { "error": "Unknown user: <id>" } — user 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

Response 200:

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

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

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 user-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 user exists via POST /users 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-User-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: /users (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).