@prismer/runtime
v2.0.8
Published
Prismer Cloud daemon runtime — TS-only adapter host for hosted IM agents
Downloads
602
Maintainers
Readme
@prismer/runtime
Prismer Cloud daemon runtime — TS-only adapter host for hosted IM agents. Same binary on macOS (LaunchAgent) and inside container pods (PID 1 of the daemon-first sandbox image).
The runtime opens one WebSocket to cloud, declares all hosted agents in a single agent.host.declare payload, dispatches per-task work to in-process adapters (Hermes / Claude Code / OpenClaw / Codex), and exposes a tiny loopback HTTP API for prismer status / sandbox-controller RPC. Cloud is the source of truth; ~/.prismer/local.db is a read-only mirror.
- Hard constraints: TS-only — no Python / Go / Rust subprocesses, no PyPI packages, no
spawn('python', …). Hermes is a pure HTTP client ofhermes gateway. Single npm package — adapters live insrc/adapters/<name>/. Local SQLite (better-sqlite3) mirrors cloud for offline-first.
Install
macOS (LaunchAgent autostart)
curl -fsSL https://prismer.cloud/install.sh | sh -s -- \
--component=daemon --adapters=claude-code,codex,hermesWhat the daemon-mode installer does:
- Detects OS / arch (Darwin or Linux x64/arm64; Windows requires WSL2).
- Installs Node.js via
fnmunder$HOME/.local/share/fnmif absent (no Homebrew, no sudo). - Strict-pins
@prismer/[email protected](no caret) and installs globally via npm. - Scaffolds
~/.prismer/{adapters,workspace,logs,bin}. - Writes
~/Library/LaunchAgents/com.prismer.daemon.plist(labelcom.prismer.daemon,KeepAlive=true,RunAtLoad=true,ProgramArgumentsincludes--foreground), thenbootout+bootstrapto load it. - Auto-runs
prismer adapter install <name>for each adapter passed via--adapters.
After install, run prismer pair (or prismer setup sk-prismer-...) to write ~/.prismer/config.toml. The LaunchAgent's polling loop snaps to attention as soon as the file appears.
Installer flags: -y/--yes, --no-setup, -v/--verbose, --local <dir> (install from local tgz pre-publish), --component=sdk|daemon (or positional daemon shorthand), --adapters=<csv>, --skip-launchagent (CI), --uninstall (rm ~/.prismer, ~/.local/share/fnm, LaunchAgent).
npm (manual)
npm install -g @prismer/[email protected]
prismer pair --device-name "$(hostname)"
prismer daemon startWithout LaunchAgent, you own daemon lifecycle (run under tmux / launchd / systemd / docker).
Container (daemon-first sandbox image)
The runtime is PID 1 of every Prismer sandbox pod. infra/sandbox-image/daemon-entrypoint.sh writes a flat ~/.prismer/config.toml from controller-injected env, then exec prismer daemon start --port=7878 --foreground. Required env:
PRISMER_API_KEY— required.PRISMER_DAEMON_ID— required (else derived from hostname →container:<podName>).PRISMER_BASE_URL(orCLOUD_API_BASE,PRISMER_CLOUD_API_BASE) — cloud URL; falls back tohttps://prismer.cloud.PRISMER_HOSTED_AGENT_FILE/PRISMER_HOSTED_AGENT_JSON— static-binding payload (one-time agent install before firsthost.declare).PRISMER_STATIC_BINDING_REQUIRED=true— make missing static binding fatal.PRISMER_USER_ID,PRISMER_TASK_ID,PRISMER_CONTAINER_ID— audit-only.
The container daemon binds :7878 (controller proxy target) instead of the desktop default :3210, and starts OutboxWatcher to upload files dropped under /workspace/_outbox/ as sandbox-output assets.
CLI reference (16 verbs)
VERSION = '2.0.0'. Each verb is a commander subcommand under src/cli/commands/. Output is JSON (--json) or human-friendly ANSI by default.
Lifecycle
| Verb | Purpose |
| ---- | ------- |
| banner [--compact] | Print the Prismer Cloud banner. --compact collapses to one line. |
| setup [api-key] [--token <jwt>] [--pair] [--cloud <url>] [--device-name <n>] [--force] [--start] [--no-start] [--as-user <email>] [--json] | First-run onboarding. Three paths: direct API key, mint a daemon-scoped key from a user JWT (POST /api/keys, label Daemon: <hostname> <YYYY-MM-DD>), or delegate to QR/local-only pair. --force overwrites config and archives local.db → local.db.<iso>.bak if api_key / cloud / daemon_id changed. Auto-prints next-step tips (daemon start, adapter list, agent register, profile create, task create). |
| pair [--cloud <url>] [--device-name <n>] [--force] [--as-user <email>] | QR/JWT pair flow. --as-user requires LOCAL_ONLY=1 (LAN dev bypass). |
| daemon start [--port <n>] [--no-local-server] [--foreground] [--json] | Daemonize itself by spawning daemon run and waiting up to 5s for the child to write a live PID. --foreground is required by the LaunchAgent plist (KeepAlive=true would respawn-loop on graceful exit). |
| daemon run [--port <n>] [--no-local-server] | Internal foreground worker. Polls every 5s for ~/.prismer/config.toml to appear before booting Runner — so LaunchAgent KeepAlive can launch pre-pair without burning CPU. |
| daemon stop [--timeout <ms>] | SIGTERM the PID in daemon.pid; default timeout 5000ms. Clears stale PID files. |
| daemon restart [--foreground] | Chained stop then start. |
| daemon status | {running, pid, paths.config}; if alive, merges loopback /healthz (pid, wsConnected, hostedAgents[]). |
| daemon logs --tail <n> [--follow] | Read ~/.prismer/logs/daemon.log; tail-N + tail -f-style follower. |
| status [--json] | Composite report: reads config.toml, hits 127.0.0.1:3210/healthz, calls GET /api/im/me, counts local.db rows (agents, agent_profiles WHERE deleted_at IS NULL, running_tasks). Pretty banner by default. |
Hosted agents
| Verb | Purpose |
| ---- | ------- |
| adapter list | Built-in adapters + per-adapter health() + recorded version from [adapters.<name>]. |
| adapter install <name> [--package-version <v>] [--with-hooks] [--dry-run] | Run the underlying package manager (npm i -g, pipx install), probe binary version, record {package_manager, package_name, binary, version, installed_at} into config.toml. --with-hooks writes a hook marker. |
| adapter remove <name> | Run uninstall (best-effort) and clear [adapters.<name>]. Alias: uninstall. |
| adapter info <name> | Print install spec + recorded version. |
| adapter doctor <name> | 5-check matrix: downstream_binary, adapter_health, recorded_config, auth_env (ANTHROPIC_API_KEY, OPENAI_API_KEY, ~/.hermes/.env, ~/.openclaw/openclaw.json), hook_config. Returns ordered fixes. |
| adapter hooks <name> [--check] [--dry-run] | Install / verify a prismer-daemon-runtime marker into the downstream tool's hook file (~/.claude/hooks.json, ~/.codex/hooks.json, ~/.hermes/hooks.json, ~/.openclaw/hooks/prismer.json). JSON files are deep-merged; existing files are backed up to <file>.bak.<iso>. |
| agent list [--local] | Merge local mirror with cloud GET /api/im/agents. Emits {localCount, cloudCount, agents[].{imUserId, name, adapter, local, cloud}}. |
| agent register --adapter <n> --display-name <n> [--username <n>] [--im-user-id <id>] [--capabilities <csv>] [--agent-type <t>] [--workspace-id <id>] | POST /api/im/register {type:'agent',username,displayName,agentType,capabilities,metadata.adapter}. Auto-derives a slug if --username omitted. Mirrors into local.db.agents. |
| agent rename <imUserId> <newName> | PATCH /api/im/agents/:id + local update. |
| agent remove <imUserId> [--cloud] | Local-only by default; --cloud also DELETEs the cloud row. |
| agent doctor [target] | 6-check matrix: binary, adapterHealth, localHosting (DB row), cloudVisibility, profileExistence (local + cloud), daemonLocalServerHealth (127.0.0.1:3210/healthz + /agents). |
| profile templates | List built-in role templates (product-manager, engineer, ceo, researcher); each carries applicableAdapters: ['hermes','openclaw','claude-code'] and a configSchema (model, systemPrompt, allowedTools, maxTokens). |
| profile list --agent <imUserId> | GET /api/im/agent_profiles?agentId=…. |
| profile create --agent <id> --name <n> [--adapter <n>] [--config <jsonOr@file>] [--from-template <n>] [--workspace-id <id>] | Template config merges with inline JSON. Default workspace via /api/im/workspaces if omitted. |
| profile edit <profileId> | Open current config in $EDITOR (default vi); PATCH on save. |
| profile remove <profileId> | Soft-delete (DELETE). |
Tasks, memory, assets, events
| Verb | Purpose |
| ---- | ------- |
| task create --agent <imUserId> --prompt <text> [--profile <id>] [--capability <n>] [--title <t>] [--timeout-ms <ms>] [--no-wait] | POST /api/im/tasks then poll GET /api/im/tasks/:id until terminal. Tolerant of {task:{...}} and flat envelopes; reads output from task.output or task.result.output. |
| task list [--limit <n>] | Default limit 20. |
| task get <taskId> | Single task fetch. |
| task cancel <taskId> | PATCH /api/im/tasks/:id {status:'cancelled'}. |
| memory stats | Daemon Memory Gateway first (/memory/stats, /api/memory/stats on PRISMER_DAEMON_URL or 127.0.0.1:3210); falls back to read-only ~/.prismer/local.db snapshot of cached_assets + workspace_files_mirror. |
| memory list [--limit <n>] | Same fallback chain. |
| memory search [query] [--query <text>] [--limit <n>] | Local-cache substring filter when gateway unavailable. |
| memory delete <id> | Gateway-only. Exits 1 if unavailable. |
| memory sync | Gateway-only. Exits 1 if unavailable. |
| asset list [--workspace-id <id>] [--task-id <id>] | GET /api/im/assets. |
| asset upload <file> --workspace-id <id> [--kind <k>] [--task-id <id>] [--agent-id <id>] [--container-id <id>] [--metadata <json>] [--mime <type>] | Multipart POST /api/im/assets. |
| asset download <assetId> --out <path> | Fetches bytes from /api/im/assets/:id and writes to disk. |
| asset get <assetId> | GET /api/im/assets/:id/detail; pretty-prints kind/mime/size/storageUri/url/s3Url/expiresIn/photoRefs count. |
| asset by-hash <sha256> [--workspace-id <id>] | Content-hash dedupe lookup (?wsId=…). |
| events [--limit <n>] [--agent-id <id>] [--session-id <id>] [--family <n>] [--type <n>] | Read ~/.prismer/para/events.jsonl. Default limit 50, hard max 10 000. Returns reverse-chronological array. Exits 1 with events_unavailable when the file does not exist. |
| events:stats [--limit <n>] [--agent-id …] | Aggregate byFamily / byType / byAgentId / bySessionId over the same filtered set. |
Interaction
| Verb | Purpose |
| ---- | ------- |
| chat me | GET /api/im/me. |
| chat direct <targetUserId> --message <text> | POST /api/im/direct/:id/messages {type:'text',content}. |
| chat messages <conversationId> [--limit <n>] [--before <id>] | Pagination via ?limit=&before=. |
| chat group create --name <n> --members <csv> | POST /api/im/groups. Members may be IM user IDs, usernames, or cloud user IDs. |
| chat group send <groupId> --message <text> | POST /api/im/groups/:id/messages. |
| chat group messages <groupId> [--limit <n>] [--before <id>] | History fetch. |
| chat group remove-member <groupId> <memberId> | DELETE /api/im/groups/:id/members/:memberId. |
All chat subcommands sanitize sk-prismer-* from error messages and tolerate both {ok,data} and {success,data} envelopes.
Cookbook (54release MVP regression)
| Verb | Purpose |
| ---- | ------- |
| cookbook run --suite <csv> [--workspace-id …] [--agent-id …] [--group-id …] [--sandbox-id …] [--prompt …] [--timeout-ms <ms>] [--strict] [--json] | Run one or more smoke suites: status (/api/health + /api/im/me), im (/workspaces, /me/agents, /groups), task (list + optional create+poll if --agent-id and --prompt provided), group (list + optional history), asset (list), sandbox (list + status). --strict treats skips as failure. Exits non-zero on fail. |
Sandbox
| Verb | Purpose |
| ---- | ------- |
| sandbox list --workspace-id <id> [--status <s>] [--limit <n>] | GET /api/sandboxes?workspaceId=…. |
| sandbox create --workspace-id <id> [--agent-id …] [--task-id …] [--image …] [--cpu-request …] [--cpu-limit …] [--memory-request …] [--memory-limit …] | POST /api/sandboxes. |
| sandbox status <id> | Detail + live status. |
| sandbox start <id> / stop <id> | Lifecycle endpoints. |
| sandbox snapshot <id> | POST /api/sandboxes/:id/snapshot. |
| sandbox runCmd <id> -- <command...> [--timeout-ms <ms>] | POST /api/sandboxes/:id/runCmd {command, timeoutMs}. |
| sandbox logs <id> | Streams /logs to stdout (ReadableStream decode loop). |
Workspace
| Verb | Purpose |
| ---- | ------- |
| workspace list / get <id> | GET /api/im/workspaces[/:id]. |
| workspace create --name <n> [--description <text>] | Slug derived from name; POST /api/im/workspaces {name, slug, metadata.description}. |
| workspace runtime <workspaceId> [--events] [--json] | GET /api/im/workspaces/:id/runtime (devices + agents + heartbeats). --events opens an SSE stream at …/runtime/events, decoding snapshot / agent.heartbeat events into pretty heartbeats or --json lines. |
| workspace files <workspaceId> | GET /api/im/workspaces/:id/files. |
Adapter taxonomy
The contract lives in src/adapters/contract.ts. Two AdapterKind values:
'long-running'— adapter exposesensureService(profile)returning anAdapterService(HTTP client) that the daemon'sServicePoolreuses across tasks perAgentProfile.id. Re-creates onhealthy() === falseor'crash'event.'interactive'— adapter exposesdispatch(profile, task)and spawns one subprocess per task.
All four official adapters ship in-process via src/adapters/registry.ts (Map<name, AdapterDef> with findByCapability(capability) supporting wildcard tags like code.*).
| Adapter | Kind | Capabilities | Wraps |
| ------- | ---- | ------------ | ----- |
| hermes | long-running | shell, code, mcp, long-context | hermes gateway HTTP API on 127.0.0.1:<port> |
| claude-code | interactive | shell, code, mcp, edit | claude --print --model … <prompt> (Claude CLI 2.x) |
| codex | interactive | code, shell, openai | codex exec --model … --cd … --sandbox … --ephemeral --json <prompt> |
| openclaw | long-running | chat, code, multi-channel | openclaw gateway (POST /v1/chat/completions, OpenAI-compatible) |
hermes
Pure HTTP client of hermes gateway (Hermes 0.10+). All calls are HTTP — no spawn('python', …), no PyPI dependency. HermesProfileConfigSchema (zod):
port(1..65535, default8642),apiKey(required, from~/.hermes/.env API_SERVER_KEY).hermesProfileName(default = first 8 chars ofprofile.id),autoStart(defaultfalse),startupTimeoutMs(default 30 000).configurePrismerProvider(defaulttrue) — writescustom_providers+model.provider='custom:prismer'+mcp_servers['prismer-tasks']block into~/.hermes/profiles/<name>/config.yaml. The MCP server path is auto-resolved viacreateRequire('@prismer/mcp-server')first, then a repo-relative dev fallback.installPrismerMcpServer(defaulttrue),prismerMcpServerPath(override).model(defaultus-kimi-k2.5),prismerProviderName(defaultprismer),prismerProviderBaseUrl(default$PRISMER_BASE_URL/api/v1),prismerApiKeyEnv(defaultPRISMER_API_KEY, regex^[A-Za-z_][A-Za-z0-9_]*$).mirrorNativeKanban(defaulttrue) — mirrors Prismerwork_itemprojections into Hermes Kanban as triage cards viahermes -p <profile> kanban create … --triage --idempotency-key prismer:<parentTaskId>.mirrorNativeGoals(defaulttrue) — writes Prismer standing objectives into Hermes' SQLitestate_meta["goal:<sessionId>"]directly.nativeMirrorTimeoutMs(default 2000).
Dispatch flow:
- (autostart only) spawn
hermes -p <profile> gateway runwithAPI_SERVER_ENABLED=true,API_SERVER_KEY,API_SERVER_PORT,API_SERVER_HOST=127.0.0.1. - Pre-bridge native Kanban + native Goals (best-effort,
nativeMirrorTimeoutMscap). POST /v1/runs {input, instructions, session_id, stream:true}→{run_id}.- Stream
GET /v1/runs/:id/events(SSE). Events:message.delta(concat to deltas),run.completed(final output),run.failed(throw),tool.started/tool.completed(forward totask.onProgresswith{kind:'tool', event, tool, summary, preview, duration, error, arguments, result}),reasoning.available(forward as{kind:'reasoning'}without bumping progress). - On abort,
POST /v1/runs/:id/stop. - Result includes
metadata.hermes.{status, lastSyncedAt, kanban, goals, runId, baseUrl, model}for cloud-side bridge persistence (PATCH-merged ontoIMTask.metadata.bridge.hermes).
claude-code
claude --print --model <m> [--system-prompt …] [--allowed-tools …] <prompt> per dispatch. --cwd removed (uses child_process.spawn cwd); prompt is positional; --headless was renamed to --print in claude CLI 2.x. Config: cwd (required), model (default 'sonnet'), systemPrompt, envVars, mcpServers, allowedTools, maxTurns (default 20), baseURL, apiKeyRef (env:NAME or keychain:NAME — keychain only on darwin via security find-generic-password), route ('default'|'prismer'|'omniroute'). stdio: ['ignore', 'pipe', 'pipe'] to close child stdin (claude ≥ 2.1.128 prints "no stdin data received in 3s, proceeding without it" otherwise). task.signal → child.kill('SIGTERM'); task.timeoutMs → SIGTERM via setTimeout.
codex
codex exec --model <m> --cd <cwd> --sandbox <level> --ephemeral --json <prompt>. Config: cwd (required), model (default codex-mini-latest), sandbox ('read-only'|'workspace-write'|'danger-full-access', default workspace-write), systemPrompt (prepended to user prompt — Codex CLI has no --system-prompt flag as of 2026-05), envVars, apiKeyEnv (default OPENAI_API_KEY). parseCodexOutput reads JSONL stdout backwards looking for {"type":"message"|"assistant"|"output", "content"|"message"|"text": string}; falls back to raw stdout. Outputs are capped at 64 KiB with \n…[truncated] suffix.
openclaw
HTTP-only client of openclaw gateway. POST /v1/chat/completions (OpenAI-compatible, non-streaming). Config: port (default 18789), apiKey (bearer token from gateway.auth.bearerTokens), model (default 'openclaw' — OpenClaw uses this to pick which agent answers, NOT the underlying LLM; primary LLM is set in openclaw.json). Includes a defensive strip of OpenClaw 2026.4.x's Cannot read properties of undefined (reading '...') stderr-bleed prefix that contaminates assistant content.
Daemon architecture
┌──────────────────────────────────────────────────────────┐
│ Cloud (REST + WS multiplexed across hosted agents) │
└─────────────┬──────────────────────────┬─────────────────┘
│ WS ?token=<sk-prismer> │ REST CloudClient
│ │
┌─────▼─────┐ ┌──────────┐ ┌───▼─────────────────┐
│ WsClient ├──▶ dispatch ├──▶ adapter.dispatch / │
│ (1 conn) │ │ router │ │ ServicePool.ensure │
└─────┬─────┘ └─────┬────┘ └─────────┬───────────┘
│ │ │
host.acked runtimeRoute long-running
host.declare ='shell' → adapters reused
task.dispatch shell-executor per AgentProfile.id
│
┌──────▼─────────────────────────────────────┐
│ Runner (main loop, ~960 LOC) │
│ - LocalDb (better-sqlite3 ~/.prismer/local.db) │
│ - AssetCache (LRU, 5 GiB default) │
│ - UriResolver (prismer:// → file://) │
│ - SyncQueue + SyncWorker │
│ - LocalServer (127.0.0.1:3210 / :7878) │
│ - OutboxWatcher (container only) │
│ - 30s heartbeat re-declare │
│ - 60s stuck-task reaper │
└────────────────────────────────────────────┘Runner boot sequence (src/daemon/runner.ts)
- Load
config.toml; exportPRISMER_BASE_URL+PRISMER_API_KEYso adapter children inherit the same provider. - Open SQLite, init asset cache, URI resolver, adapter registry,
ServicePool. - Static-binding preload —
installStaticHostedAgentFromEnvreadsPRISMER_HOSTED_AGENT_FILE/PRISMER_HOSTED_AGENT_JSONand seeds oneagents+agent_profilesrow before the firstagent.host.declare.PRISMER_STATIC_BINDING_REQUIRED=1makes a missing payload fatal. - Load remaining locally-registered agents from DB.
- Spin
SyncWorker(local-write → cloud-flush queue: PATCH/POST/DELETE per resource type, 4xx-other dropped, 5xx/408/429/0 retried). - Open WS; wire handlers.
- (Container only —
PRISMER_CONTAINER_IDset orPRISMER_RUNTIME_MODE=container) startOutboxWatcheron/workspace/_outbox/(uploads as sandbox-output assets, marks staged in_uploaded/). - Bind
LocalServer(unless--no-local-server). - Start a 30s heartbeat that re-sends
agent.host.declareso the cloud's 90ssweepTimedOut()doesn't flip status back offline. - Start a 60s stuck-task reaper: any
runningTasksentry pastmax(timeoutMs, 5min)getsctrl.abort()+ a daemon-sidetask.dispatch.reply {ok:false, error.code:'daemon_task_timeout'}sent over WS — safety valve for upstream LLM gateway half-closes.
Dispatch (src/daemon/dispatch.ts)
Pure function that runs a single task:
- Resolve
AgentProfile(cloud REST — byprofileIdor, if blank, the most-recently-created profile foragentImUserId). - Look up adapter; reply
{ok:false, error.code:'adapter_unhealthy'}if missing. - Rewrite
prismer://URIs inpromptandcontext[].contentviaUriResolver, pinning resolved hashes. composePrompt(...)— concat context history + current message; trim oldest first when total chars exceedcontextMaxChars(default 8000); always keep at least one entry.- Memory context —
GET /api/im/memory/digest?maxLines=120&maxBytes=4000→ prepend[Memory Context]block when non-empty. - Goal context —
GET /api/im/tasks?workspaceId=<ws>&limit=100, filter formetadata.kind==='goal' || intent==='standing_objective', dedupe by assignment, sort byupdatedAt, take top 4 → prepend[Active Goals]block. - Resolve
profile.config.systemPrompt(warn loudly if non-string — without this, role-template-driven agents silently regress to defaults). - Build
TaskInput { taskId, prompt, metadata: {...payload.metadata, conversationId, prismerGoals, systemPrompt?, prismerObservability}, timeoutMs, signal, onProgress }.onProgressre-emits astask.dispatch.progressover WS. - Long-running:
servicePool.ensureService(profile, adapter).dispatch(taskInput). Interactive:adapter.dispatch(profile, taskInput). - Build
task.dispatch.reply { taskId, ok, output, error, metrics }. - Bridge metadata writeback — if
result.metadata.hermespresent, PATCH/api/im/tasks/:idmerging ontometadata.bridge.hermes(withlastSyncedAt). - Observability writeback — PATCH
metadata.observabilitywith{identity, memory, goals, lastSyncedAt}. - Always unpin all resolved hashes.
Daemon-local shell (src/daemon/shell-executor.ts)
runtimeRoute='shell' (or metadata.execution.kind === 'shell') routes the dispatch to executeShellDispatch instead of an adapter. ShellExecutionConfig (resolved from [shell] block in config.toml):
enabled(defaultfalse;PRISMER_SHELL_ENABLED=trueenv override).defaultCwd(defaultprocess.cwd()).maxTimeoutMs(default 60s, hard max 30 min).maxOutputBytes(default 256 KiB, hard max 5 MiB).allowedWorkspaces?(allow-list).shell(bash|zsh|sh, defaultbash).
Spawns <shell> -lc <command> with payload.metadata.execution.cwd|env|shell overrides; streams stdout/stderr → task.dispatch.progress events with detail.{stream, chunk, sequence, truncated}. Output (capped) is rendered as $ <cmd>\ncwd: …\nshell: …\nexitCode: N\n[stdout]\n…\n[stderr]\n…. Structured error codes: shell_disabled, shell_workspace_not_allowed, shell_command_required, shell_cwd_missing, shell_spawn_failed, shell_timeout, task_cancelled, shell_exit_nonzero. SIGTERM → 2s grace → SIGKILL on timeout/abort.
WsClient (src/daemon/ws-client.ts)
WS to cloud, ?token=<apiKey>. Exponential backoff 1s → 60s, capped at 30 attempts before 5min cooldown ("degraded"). Special close codes: 1000 normal (no reconnect), 4001 AUTH (no reconnect, emit auth-failed). Events: open|message|close|error|drop|auth-failed|degraded|reconnect-scheduled.
WS protocol (cloud ↔ daemon)
Mirror types in src/types/im-events.ts (canonical chain: src/im/types/im-events.ts → SDK → runtime).
Daemon → cloud
| Event | Payload |
| ----- | ------- |
| agent.host.declare | {daemonId, daemonVersion, platform: 'darwin'|'linux'|'win32', agents: [{imUserId, name, adapterName, capabilities, profiles:[{id,version}]}]}. Sent on 'authenticated' ack and every 30s. Cloud stamps daemonId into IMAgentCard.metadata so the workspace runtime view groups daemon-declared agents under the right device; cloud also writes a Redis presence record at runtime:device:<wsId>:<daemonId> (TTL 90s). |
| task.dispatch.reply | {taskId, ok, output?, error?, metrics?}; requestId echoes the original request envelope. |
| task.dispatch.progress | {taskId, progress: 0..1, message?, detail?} — incremental. |
| agent.status.changed | {agentImUserId, status, activeProfileId?, runningTaskIds?} — emitted on agent state shifts. |
Cloud → daemon
| Event | Payload |
| ----- | ------- |
| host.acked | {workspaceId, syncCursor: {workspaces, agent_profiles}, profilesToSync: string[]} — first reply post-declare; cloud also fires redispatchPending(userId) once per WS connection so any tasks accumulated while offline come back through task.dispatch.request. |
| task.dispatch.request | {taskId, agentImUserId?, targetDaemonId?, profileId, capability, prompt, runtimeRoute?, metadata?, timeoutMs?, context?: TaskDispatchContextEntry[], conversationId?}. runtimeRoute accepts 'agent' | 'sandbox' | 'shell'. Per-taskId dedupe in Runner.runningTasks is what makes redispatch resilient to cloud's heartbeat-redeclare loop. |
| task.cancel | {taskId, reason?} → runningTasks.get(taskId)?.ctrl.abort(). |
| agent.changed | {agentImUserId, fields: {displayName?, capabilities?}} → patch in-memory. |
| agent_profile.changed | {profileId, version} → re-fetch via REST + redeclare. |
| workspace.changed | {workspaceId, updatedAt} → re-fetch. |
| workspace_file.changed | {workspaceId, path, operation: 'create'|'update'|'delete', assetId?, contentHash?, version} → upsert/delete workspace_files_mirror. |
The server-side 'authenticated' ack from cloud triggers the first agent.host.declare (declaring on open is racy because cloud auth is an async DB lookup).
Hermes long-running setup
Operators run hermes in their own Python venv. Sample ~/.hermes/profiles/<name>/config.yaml (auto-written by the adapter when configurePrismerProvider: true):
custom_providers:
prismer:
type: openai
base_url: https://prismer.cloud/api/v1
api_key_env: PRISMER_API_KEY
model:
provider: custom:prismer
name: us-kimi-k2.5
mcp_servers:
prismer-tasks:
command: node
args:
- /usr/local/lib/node_modules/@prismer/mcp-server/dist/index.jsThen start the gateway with the API server platform enabled:
API_SERVER_ENABLED=true \
API_SERVER_KEY=<apiKey> \
API_SERVER_PORT=8642 \
API_SERVER_HOST=127.0.0.1 \
hermes -p <profile> gateway runA plain hermes gateway without these env vars starts only configured messaging platforms and will never bind the /v1/runs HTTP API. Set autoStart: true on the AgentProfile to let the daemon spawn this command on first dispatch.
The adapter mirrors Prismer projections into Hermes' native surfaces:
- Kanban —
hermes -p <profile> kanban create … --triage --idempotency-key prismer:<parentTaskId>. Triage avoids duplicate execution; Prismer'sagent_runremains the executable source of truth. - Goals — direct write into Hermes' SQLite
state_meta["goal:<sessionId>"](Hermes has no public goals REST/CLI surface).
Bridge results are persisted on IMTask.metadata.bridge.hermes via the daemon's PATCH-merge writeback step.
LocalServer HTTP API (loopback only)
src/daemon/local-server.ts binds 127.0.0.1:<port>. Desktop default :3210, container :7878. CORS allows * / GET,POST,OPTIONS / Content-Type,Authorization.
| Method | Path | Purpose |
| ------ | ---- | ------- |
| OPTIONS | * | CORS preflight |
| GET | /healthz | {status:'ok', daemonId, cloudBaseUrl, workspaceId, pid, startedAt, wsConnected, hostedAgents, observability} |
| GET | /agents | {agents: [{imUserId, name, adapterName}]} |
| GET | /tasks/running | {taskIds: string[]} |
| POST | /v1/runs | Sandbox controller daemon-dispatch ack — 202 {runId, status:'accepted', taskId}. Optional payload.shellCommand triggers async bash -c <cmd> with cwd=/workspace (escape hatch). |
| POST | /v1/agents/install | Cloud → daemon agent install RPC: validates body via validateInstallAgentPayload, calls runner.installHostedAgent → upsert agents + agent_profiles rows, reload, send agent.host.declare, return {ok, daemonId, installedAgent, hostedAgents}. |
| POST | /v1/snapshot | FS-manifest snapshot — walks snapshotRoot (default /workspace), computes per-file {path, sha256, sizeBytes, mtime}. Skips dotfiles + _outbox/_uploaded. 404 if root missing. |
The sandbox-controller's installAgent proxy at POST /:id/installAgent validates body via InstallAgentSchema (zod), resolves pod IP via orchestrator.getContainerIp(id), and forwards to http://<podIP>:7878/v1/agents/install. The earlier daemonDispatch proxy at POST /:id/daemonDispatch forwards {taskId, adapter?, prompt?, env?} to :7878/v1/runs.
Configuration
| Env var | Default | Purpose |
| ------- | ------- | ------- |
| PRISMER_HOME | ~/.prismer | Daemon home (config, db, logs, adapter installs) |
| PRISMER_BASE_URL | from config.toml cloud_api_base | Cloud URL (LAN dev override) |
| PRISMER_API_KEY | from config.toml api_key | Cloud API key (also exported to adapter children) |
| PRISMER_HOSTED_AGENT_FILE | (empty) | Static-binding payload path (k8s/container) |
| PRISMER_HOSTED_AGENT_JSON | (empty) | Inline JSON variant |
| PRISMER_STATIC_BINDING_REQUIRED | false | Treat missing static binding as fatal |
| PRISMER_CONTAINER_ID | (empty) | Activates OutboxWatcher |
| PRISMER_RUNTIME_MODE | (empty) | container activates OutboxWatcher (alt to PRISMER_CONTAINER_ID) |
| PRISMER_DAEMON_URL | http://127.0.0.1:3210 | Loopback URL used by prismer memory and agent doctor |
| PRISMER_SHELL_ENABLED | false | Override [shell].enabled in config.toml |
| LOCAL_ONLY | (empty) | =1 enables --as-user pair bypass |
| PRISMER_AUTH_TOKEN | (empty) | Fallback for prismer setup --token <jwt> |
| EDITOR | vi | Used by prismer profile edit |
config.toml schema (TOML, written by setup / pair):
api_key = "sk-prismer-..."
cloud_api_base = "https://cloud.prismer.dev"
daemon_id = "..."
[adapters.claude-code]
package_manager = "npm"
package_name = "@anthropic-ai/claude-code"
binary = "/usr/local/bin/claude"
version = "2.1.128"
installed_at = "2026-05-07T00:00:00.000Z"
[shell]
enabled = false
maxTimeoutMs = 60000
maxOutputBytes = 262144
shell = "bash"Troubleshooting
- LaunchAgent respawn loop after install — the plist must include
--foregroundinProgramArguments. v2.0.0 installs do this automatically; if you're upgrading from 1.8.x re-runcurl -fsSL https://prismer.cloud/install.sh | sh -s -- --component=daemon(the installer doesbootout+bootstrapto reload). - Daemon shows "waiting for ~/.prismer/config.toml" — that's expected behavior pre-pair. Run
prismer pair(orprismer setup sk-prismer-...) and the daemon snaps to attention within 5s. - Cloud unreachable —
prismer statuswill print the failing base URL. Common causes: stalecloud_api_baseafter a tenant move (runprismer setup --force <new-key> --cloud <url>), or the API key was rotated (mint a new one withprismer setup --token <jwt>). - Agent shows under
__unbound__device in workspace runtime view — the daemon'sagent.host.declareis not stampingdaemonId. Confirm you're on@prismer/[email protected](thedaemonIdpayload field is required as of v2.0.0). claude --printruns report "no stdin data received in 3s" — fixed in v2.0.0 (stdio: ['ignore', 'pipe', 'pipe']). Older runtimes will surface the warning but the dispatch still completes.- Tasks dispatch but
@-mention messages never reach the agent — cloud-sideSHADOW_JOIN_FAILEDrollback. The runtime'sagent.host.declarerequires every declared agent to auto-join all its conversation rooms; check daemon logs for the rollback line and re-register the agent (prismer agent register …) to fix the cloud-side row. - Stuck task that never replies — the 60s reaper aborts and replies
{ok:false, error.code:'daemon_task_timeout'}aftermax(timeoutMs, 5min). Cloud will mark the taskfailedaccordingly. Inspect viaprismer task get <id>.
Versioning
See CHANGELOG.md. Current: v2.0.0 (2026-05-19).
License
MIT.
