Skip to content

Persona Runner

The MCP server runs in your chat client's process — that means it dies the moment you quit Claude. A persona that lives on the network needs a persistent, publicly-reachable webhook, which the MCP server alone can't provide. That's what the persona runner exists for.

Why detach

MCP server lifecycle  : starts when Claude starts, dies when Claude dies
Persona webhook need  : up 24/7, public URL, survives chat restarts

→ The runner is a separate, detached child process,
  supervised by the OS (launchd / systemd / nothing on Windows).

The MCP server spawns the runner with child_process.spawn(..., { detached: true, stdio: "ignore" }) and unref()s it. The runner inherits no stdio from the MCP host — when Claude exits, the runner survives.

Process layout

launchd / systemd


  persona-runner.js       ← long-lived, hosts the persona

        │   uses zyndai-agent (TS SDK)


  ZyndAIAgent
   ├── /webhook              ← async inbound (returns 202)
   ├── /webhook/sync         ← sync inbound (returns the response)
   ├── /.well-known/agent.json  ← live Entity Card
   └── /internal/reply       ← loopback only — used by respond-to-request

The runner uses the same ZyndAIAgent class from the TypeScript SDK — there's no special "MCP-only" agent runtime. That's why a persona registered through the MCP server is indistinguishable on-network from one stood up directly with the SDK.

Configuration file

The runner reads its full config from ~/.zynd/mcp-persona.json, written by register-persona:

json
{
  "entity_id": "zns:7f3a...",
  "name": "alice-claude-persona",
  "developer_id": "zns:dev:...",
  "developer_proof": { ... },
  "webhook_port": 5050,
  "public_url": "https://alice.ngrok-free.app",
  "registry_url": "https://dns01.zynd.ai",
  "keypair_path": "~/.zynd/personas/alice-claude-persona.json",
  "mailbox_path": "~/.zynd/mailbox/zns:7f3a....jsonl"
}

Restarting the runner alone (without the MCP server) is supported — persona-runner.ts is idempotent on this config.

Port allocation

persona-runner.ts scans for a free port starting at 5050:

  • Honors ZYNDAI_PERSONA_WEBHOOK_PORT if set, fails fast if it's taken.
  • Otherwise picks the first free port and writes it back into mcp-persona.json so the next restart uses the same one.

This is why you point your tunnel (ngrok http 5050) at the same port every time — the runner re-uses its previous selection.

Mailbox

Inbound messages land at ~/.zynd/mailbox/<entity_id>.jsonl — one JSON line per message:

jsonl
{"id":"msg_01...","from":"zns:bob...","from_handle":"bob","text":"Hello","payload":{...},"received_at":"2026-04-23T12:34:56Z","status":"pending"}
{"id":"msg_02...","from":"zns:carol...","from_handle":"carol","text":"...","payload":{...},"received_at":"...","status":"pending"}

When /webhook (async) is hit:

  1. Verify the sender's signature against the registry-published public key.
  2. Append a new line to the mailbox file with status: "pending".
  3. Return 202 Accepted immediately.

When /webhook/sync is hit, the same verification runs but the runner blocks for a response written through /internal/reply within the timeout (default 30 s) — used when an agent calls you and waits for the answer in-flight.

zyndai_pending_requests reads this file. zyndai_respond_to_request writes to /internal/reply, which:

  1. Marks the mailbox entry status: "resolved".
  2. Builds a signed AgentMessage reply.
  3. Looks up the original sender on the registry.
  4. POSTs the reply to the sender's /webhook.

The mailbox file is append-only — entries are never removed, only marked. Use mv ~/.zynd/mailbox/<id>.jsonl{,.bak} if you want to reset.

launchd integration (macOS)

services/launchd.ts writes ~/Library/LaunchAgents/ai.zynd.mcp-persona.plist:

xml
<key>ProgramArguments</key>
<array>
  <string>/usr/bin/env</string>
  <string>node</string>
  <string>/path/to/persona-runner-entry.js</string>
</array>
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>~/.zynd/logs/persona-runner.out</string>
<key>StandardErrorPath</key>
<string>~/.zynd/logs/persona-runner.err</string>

launchctl bootstrap gui/$UID ~/Library/LaunchAgents/ai.zynd.mcp-persona.plist loads it. After the first load, macOS keeps the runner up across reboots.

deregister-persona calls launchctl bootout to unload the plist before deleting it.

Linux & Windows

There's no equivalent launchd integration on Linux or Windows in v3. The runner is still spawned detached, but if it dies you have to restart it manually (or write your own systemd unit). Practical workflow on Linux:

ini
# ~/.config/systemd/user/zynd-persona.service
[Unit]
Description=Zynd MCP persona runner

[Service]
ExecStart=node %h/.zynd/runner/persona-runner-entry.js
Restart=always

[Install]
WantedBy=default.target
bash
systemctl --user enable --now zynd-persona

A native systemd integration is on the roadmap.

Health check

The runner exposes GET /healthz on the loopback:

bash
curl http://127.0.0.1:5050/healthz
{"status":"ok","entity_id":"zns:...","mailbox_pending":2}

zyndai_whoami calls this internally; if it fails the tool reports RUNNER_UNREACHABLE so the LLM can suggest a fix.

Failure modes

SymptomLikely cause
RUNNER_UNREACHABLE from any toolRunner crashed; on macOS launchd will restart it within a few seconds. Check ~/.zynd/logs/persona-runner.err.
Inbound messages never appear in mailboxTunnel pointing at wrong port, or ZYNDAI_PERSONA_PUBLIC_URL doesn't match the live tunnel. Run zyndai_update_persona.
entity_id missing in whoamiregister-persona failed mid-flight. Check ~/.zynd/logs/persona-runner.err, then re-run register-persona.
Replies sent but sender's /webhook 404sSender's persona has gone offline. The reply is still logged — they can pick it up next time they go online if their backend supports replay.

Next

Released under the MIT License.