springtaled exposes a REST API over HTTP for connector management, rule CRUD, formation orchestration, event streaming, canvas updates, configuration, and webhook ingestion.
- Default bind:
127.0.0.1:8080(configurable via[api] bind) - Transport: HTTP (use a reverse proxy for remote TLS termination)
- Content type:
application/json - Live streams: one multiplexed Server-Sent Events endpoint,
GET /stream, opened with a one-time ticket fromPOST /stream/ticket(see §1)
Client springtaled
│ │
│ HTTP request │
│ Authorization: Bearer X │
├───────────────────────────>│
│ │
│ ┌──────┴────────────────────┐
│ │ Middleware Stack │
│ │ │
│ │ 1. ValidatedPath (≤256B) │
│ │ 2. require_auth │
│ │ 3. RateLimit (100 req/s) │
│ │ 4. Buffer │
│ │ 5. BodyLimit (1 MiB) │
│ │ 6. Timeout (30 s) │
│ │ 7. CSP / X-Frame-DENY │
│ └──────┬────────────────────┘
│ │
│ JSON response │
│<───────────────────────────┤
Fig. 1. Request flow through the middleware stack.
Every route except /health, /ready, /openapi.json, POST /auth/login, POST /vault/unlock, and /ui requires a bearer token. Webhook routes (/webhook/{connector}/{trigger}) also require the token — the connector then performs its own signature verification on the body (HMAC-SHA256 for GitHub, RSA for Kick, etc.) inside its verify_webhook() implementation.
Bearer tokens are issued, never derived. POST /auth/login takes the
vault passphrase, compares HMAC-SHA256(passphrase, "springtale-api-token")
against the value the daemon computed at boot — in constant time
(subtle::ConstantTimeEq) — and only on a match mints a token: 32 bytes
(256 bits) straight from the OS CSPRNG, hex-encoded, with no structure at
all. That passphrase-derived hash is the login verifier only; it is
never accepted as a bearer.
Two kinds of bearer exist, and require_auth accepts either:
| Kind | Minted by | Lifetime | Revoked by |
|---|---|---|---|
| Session | POST /auth/login |
Idle + absolute timeouts from bot:settings (session_idle_secs, session_absolute_secs). Held in process memory only, so a daemon restart or a vault lock drops every one. |
POST /auth/logout |
| Long-lived named | POST /auth/tokens |
Until revoked. Persisted in the api_tokens table. |
DELETE /auth/tokens/{id} |
Neither kind is ever stored in the clear: both live as sha256(token) —
sessions in the in-memory map, long-lived tokens in api_tokens. The token
string is returned exactly once, in the minting response, and nothing keeps
it. A presented bearer is hex-decoded, hashed, and looked up (sessions
first, then api_tokens) with a constant-time compare, so a hit and a miss
cost the same work. A token that was never issued, one that expired, and
one that was revoked are all the same answer: 401.
POST /auth/login is unauthenticated by definition, so it carries its own
tight rate limit (5 req/s) on top of the global 100 req/s.
Authenticated routes also go through a CSRF-protection middleware
(require_csrf_protection) that rejects cross-origin requests with
unsafe methods. SSE readers are exempt because the browser EventSource
cannot originate a state-changing request.
# Log in once, then use the minted token
TOKEN=$(curl -sX POST http://127.0.0.1:8080/auth/login \
-H 'Content-Type: application/json' \
-d '{"passphrase":"..."}' | jq -r .token)
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/connectorsSSE authentication. EventSource cannot set custom headers, and the bearer token is never put in a URL. The client calls POST /stream/ticket (bearer-authenticated) for a one-time ticket valid for 30 seconds, then opens the single multiplexed GET /stream with it. A ticket is consumed on first use and expires unused.
springtaled HTTP API
│
┌─────────┬──────────┬────────┼────────┬──────────┬──────────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼
Health Connectors Rules Formations Canvas Events Config
(SSE)
┌─────────┬──────────┬────────┼────────┬──────────┬──────────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼
Agents Authors Bot admin Sessions Memory Safety Data
┌─────────┬──────────┬──────────┬──────────┬─────────────┐
▼ ▼ ▼ ▼ ▼ ▼
Send Diagnostics Fixes Onboarding Recipes Webhooks
┌──────────────┬──────────────────┬──────────────┬──────────┐
▼ ▼ ▼ ▼ ▼
Approvals Chat (SSE) Dashboard (SPA) Auth Vault
│
▼
MCP
Fig. 3. Route groups at a glance. Public routes: /health, /ready, /openapi.json, POST /auth/login, POST /vault/unlock, /ui, /ui/*. Everything else requires the bearer token.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
— | Liveness. Returns {"status":"ok"}. |
| GET | /ready |
— | Readiness. 200 when booted, 503 while booting. |
| Method | Path | Description |
|---|---|---|
| GET | /connectors |
List installed connectors with enabled state |
| GET | /connectors/schemas |
JSON Schema for every connector's config |
| GET | /connectors/available |
Built-in + community connectors discoverable for install |
| POST | /connectors/setup |
Interactive setup wizard — validates config |
| POST | /connectors/install |
Install from manifest. Verifies Ed25519 signature before registering |
| DELETE | /connectors/{name} |
Remove a connector. Leaves rules intact. |
| DELETE | /connectors/{name}/cascade |
Remove connector and any rules that reference it |
| GET | /connectors/{name}/config |
Stored config (secrets redacted) |
| POST | /connectors/{name}/upsert-config |
Update connector config atomically |
| GET | /connectors/{name}/outputs |
Recent action outputs (cap 100, oldest dropped) |
| POST | /connectors/{name}/enable |
Enable a disabled connector |
| POST | /connectors/{name}/disable |
Disable without removing |
| POST | /connectors/{name}/reload |
G4 — hot-reload a connector's running instance against the latest stored config without restarting the daemon |
| POST | /connectors/{name}/test |
Dry-run an action with synthetic input |
| Method | Path | Description |
|---|---|---|
| GET | /rules |
List all rules with status + trigger type |
| POST | /rules |
Create a rule from JSON |
| POST | /rules/parse |
Natural-language → Rule via the configured AI adapter |
| GET | /rules/schema |
JSON Schema for the Rule type |
| PUT | /rules/{id} |
Replace a rule |
| DELETE | /rules/{id} |
Delete a rule |
| POST | /rules/{id}/toggle |
Enable ↔ disable |
| POST | /rules/{id}/run |
Dry-run against a synthetic trigger event |
| POST | /rules/{id}/reassign |
Reassign a rule to a different connector or agent |
| POST | /rules/connector |
Create a rule for a connector event (convenience wrapper) |
| GET | /rules/connector/{name} |
List rules triggered by a specific connector |
Formations are cooperating groups of agents with a shared intent. See docs/guide/cooperation.md. Every formation command pushes onto the bot's FormationCommand channel; the bot is the only code path that materialises live Formation structs from DB rows.
| Method | Path | Description |
|---|---|---|
| GET | /formations |
List all formations with member count, intent, momentum tier |
| POST | /formations |
Create a formation |
| GET | /formations/{id} |
Live formation detail (momentum, rally tokens, attention load, guard status, member health + liveness) via LiveFormationReader |
| GET | /formations/{id}/commands |
3×3 colony-canvas command grid for this formation, with status-aware enable flags. Used by the dashboard formation detail card. |
| GET | /formations/{id}/members/eligible |
Eligible-for-removal member list for the formation member overlay. |
| GET | /formations/intents |
List available intent templates (Reconnoiter / Execute / Stabilize / Surge / Dissolve) |
| POST | /formations/deploy-team |
Deploy a multi-agent team in one call |
| POST | /formations/{id}/deploy |
Move formation to deployed state |
| POST | /formations/{id}/pause |
Pause (members stop acting on cadence ticks) |
| POST | /formations/{id}/resume |
Resume from paused |
| POST | /formations/{id}/dissolve |
Dissolve, persist mental model first, stop all members |
| POST | /formations/{id}/rally |
Manual rally — consume a rally token around the weakest member |
| PUT | /formations/{id}/intent |
Change intent |
| POST | /formations/{id}/members |
Add a member (by connector name) |
| DELETE | /formations/{id}/members |
Remove a member (by connector name) |
| POST | /formations/{id}/cycle-intent |
Cycle to the next intent template |
| POST | /formations/{id}/cycle-autonomy |
Cycle autonomy level of all members (observe → suggest → approve → autonomous → observe) |
| POST | /formations/{id}/toggle-guard |
Toggle the formation guard rails |
| GET | /cooperation/events |
SSE stream of formation lifecycle, momentum transitions, rally events, and interference events. Carried on the multiplexed GET /stream (ticket from POST /stream/ticket); optional ?formation_id=... filter. The dashboard's live cooperation overlay consumes this. |
Autonomy levels: observe → suggest → act-with-approval → act-autonomously.
| Method | Path | Description |
|---|---|---|
| GET | /agents/states |
All agents with current autonomy and formation membership |
| GET | /agents/{name}/autonomy |
Current autonomy level for one agent |
| PUT | /agents/{name}/autonomy |
Set autonomy level |
| POST | /agents/{name}/autonomy/step |
Step one level toward more autonomous |
The colony canvas is a live pixel-art visualisation of connectors, rules, agents, and formations.
| Method | Path | Description |
|---|---|---|
| GET | /canvas |
Full canvas state (nodes + edges) |
| GET | /canvas/connections |
Just the edge metadata |
| GET | /canvas/stream |
SSE stream of canvas updates. Carried on the multiplexed GET /stream; ticket from POST /stream/ticket |
Note: there is no
POST /canvas/updateHTTP endpoint. Canvas layout changes (drag, reposition, re-wire) happen in the Tauri desktop via the dashboard's IPC layer, which writes directly to the dashboard's local layout state. The daemon-sideCanvasStateis read-only over HTTP.
| Method | Path | Query params | Description |
|---|---|---|---|
| GET | /events |
limit, offset, connector |
Paginated event log |
| GET | /events/stream |
ticket | SSE stream of new events, carried on the multiplexed GET /stream |
| Method | Path | Description |
|---|---|---|
| GET | /config |
List all config keys |
| GET | /config/{key} |
Get a single config value |
| PUT | /config/{key} |
Set a single config value |
| POST | /config/ai |
Select AI adapter (noop, ollama, openai, anthropic) — hot-swaps at runtime |
| POST | /config/ai/configure |
Adapter-specific settings (endpoint, model, API key) |
| POST | /config/connector/{name} |
Store encrypted connector config |
| GET | /config/heartbeat |
Heartbeat interval (seconds) |
| PUT | /config/heartbeat |
Update heartbeat interval |
Author Ed25519 public keys used to verify signed manifests.
| Method | Path | Description |
|---|---|---|
| GET | /authors |
List registered author keys |
| POST | /authors/{name} |
Register a new author key |
| DELETE | /authors/{name} |
Remove an author |
| Method | Path | Description |
|---|---|---|
| GET | /bot/status |
Bot connection status for each chat connector |
| GET | /bot/formations |
Active bot formations |
| GET | /bot/memory |
Memory session statistics |
| Method | Path | Description |
|---|---|---|
| GET | /sessions |
Active agent sessions |
| POST | /memory/audit |
Inspect memory session counts |
| POST | /memory/compact |
Delete oldest entries beyond per-session limit |
| Method | Path | Description |
|---|---|---|
| GET | /safety |
Current sentinel / behavioural monitor config |
| PUT | /safety |
Update full safety config (window title, auto-lock, content protection, disguise profile, quick-hide shortcut) |
| POST | /safety/disguise/active |
G5d — flip just the disguise-active flag. Focused endpoint that avoids the lost-update race two tabs would hit on the full-config PUT path. Body: { "active": bool } |
| POST | /safety/disguise/profile |
G5f — switch the disguise icon profile (one of calculator, files, notes, springtale). Body: { "profile": "<id>" }. Tray icon is swapped at runtime |
| POST | /safety/panic_tap_count |
Set the number of taps required on the panic gesture before the wipe fires. Body: { "count": u32 } |
| Method | Path | Description |
|---|---|---|
| POST | /data/export |
Export all user data. Optional encryption with vault passphrase. |
Note: import is CLI-only.
springtale-cli data import --input <path>calls the runtime'simport_data()function directly against the local SQLite backend. There is no HTTP endpoint because import is an offline operation that requires the daemon to not be writing concurrently.
| Method | Path | Description |
|---|---|---|
| POST | /send |
Execute an Action directly against a connector. Capability-checked through the sentinel, same as rule-dispatched actions. No back door. |
These routes back the Doctor and Onboarding flows in the desktop shell.
| Method | Path | Description |
|---|---|---|
| GET | /diagnostics |
Run the current set of runtime health checks; returns a list of issues with severity + description |
| GET | /fixes |
List available auto-repair suggestions bound to diagnostic ids |
| GET | /fixes/{id} |
Fetch a single fix with its proposed action |
| POST | /fixes/{id}/apply |
Apply a fix |
| GET | /onboarding/platforms |
List the platform forms the first-run wizard knows. Collected at runtime from every registered connector factory that returns an onboarding_form() — currently telegram, discord, slack, signal |
| POST | /onboarding/{platform} |
Persist a completed answer set for that platform as a connector config |
| GET | /recipes |
List curated automation recipes (browseable cookbook surface) |
| GET | /recipes/categories |
Recipe categories |
| GET | /recipes/{id} |
One recipe by id |
| GET | /recipes/{id}/pieces |
Modular pieces composing the recipe |
| GET | /recipes/{id}/export |
Export the recipe as TOML |
| POST | /recipes/{id}/favorite |
Toggle favorite |
| POST | /recipes/{id}/recent |
Mark recently viewed |
| POST | /recipes/{id}/apply |
Apply the recipe — installs rules / connectors as defined |
| POST | /recipes/{id}/render |
Render the recipe against current state (preview-friendly) |
| POST | /recipes/{id}/preflight |
Check preconditions (capability grants, connector availability) before apply |
| POST | /recipes/{id}/preview |
Dry-run preview of what apply would do |
| POST | /recipes/{id}/fork |
Fork a recipe into a user-saved variant |
| POST | /recipes/user |
Save a custom user recipe |
| DELETE | /recipes/user/{id} |
Delete a user-saved recipe |
| POST | /recipes/import |
Import a recipe from TOML |
| Method | Path | Description |
|---|---|---|
| POST | /webhook/{connector}/{trigger} |
Inbound webhook, forwarded to the connector's trigger handler after token auth |
The endpoint requires the bearer token like every other authenticated route. External senders need the token in the Authorization header. In addition, each connector performs its own webhook signature verification on the body via Connector::verify_webhook() — HMAC-SHA256 for GitHub, RSA for Kick, and so on.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /ui |
— | Embedded SPA index |
| GET | /ui/{*path} |
— | SPA static assets |
Blocking-approval queue for capabilities that can never be auto-granted
(currently ShellExec — the OpenClaw CVE-2026-25253 1-click-RCE class).
When a connector invokes such a capability, the requestor blocks until a
decision lands here or the gate's deny-fallback timeout fires (default
60s). Every decision is written to the sentinel audit trail.
| Method | Path | Description |
|---|---|---|
| GET | /approvals |
List outstanding approval requests. Returns {"pending": [...]} — each entry carries the connector name, requested capability, human-readable summary, and the request id |
| POST | /approvals/{id} |
Resolve a pending request |
POST /approvals/{id} body:
{ "decision": "approve" | "deny", "approver": "optional label", "reason": "optional deny reason" }approver defaults to "maintainer" (the bearer token already proves
authority; the field is only audit-row attribution). Status codes: 200
decision recorded, 400 malformed id/body, 404 no pending request with
that id (timeout already fired), 409 already resolved, 503 no
approval gate wired.
In-app chat surface (W5). The desktop app, web dashboard, and PWA talk to
the bot through one channel without any external chat platform. Messages
are injected as if they arrived from a synthetic connector named
in-app; replies stream back over SSE. Approvals raised by in-app tasks
surface through the §3.18 endpoints.
| Method | Path | Description |
|---|---|---|
| POST | /chat |
Inject a chat message. Body {"text": "...", "session": "optional"} (session defaults to in-app). Fire-and-forget — returns 202 Accepted with {"status":"queued","session":"..."} once queued; 400 on empty text, 503 if the bot runtime is unavailable |
| GET | /chat/stream |
SSE stream of bot replies. Each event's data is {"session": "...", "text": "..."} |
See §2 for the token model. POST /auth/login is public; everything else
in this family requires an existing bearer.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /auth/login |
— | Verify the vault passphrase and mint a session token. Body {"passphrase": "..."}; returns {"token": "<hex>", "expires_in": <idle secs>}. 401 on mismatch. Rate-limited to 5 req/s on top of the global limit |
| POST | /auth/logout |
✓ | Drop the presented session. Returns {"logged_out": true|false}. A long-lived token is not a session — revoke those with DELETE /auth/tokens/{id} |
| POST | /auth/tokens |
✓ | Mint a long-lived named token. Body {"name": "springtale-cli@laptop"} (1–128 chars). Returns {"id", "name", "token"} — the token string appears here and nowhere else, ever |
| GET | /auth/tokens |
✓ | List long-lived tokens: id, name, created_at, last_used. Metadata only — the hash never crosses the wire |
| DELETE | /auth/tokens/{id} |
✓ | Revoke a long-lived token. Revocation is immediate: the next request carrying it fails its lookup |
| POST | /stream/ticket |
✓ | One-time 30 s ticket for the SSE routes, bound to the presented bearer. Logging out or revoking that bearer invalidates every outstanding ticket |
While the vault is locked the daemon swaps in an outer router: /health,
/ready, and the two vault routes always answer, and everything else is
refused until the vault is opened.
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /vault/unlock |
— | Open the vault. Body {"passphrase": "..."}. Deliberately unauthenticated: while locked there is no bearer that could be presented — sessions are dropped with the process state on lock, and a long-lived token can only be looked up against that same dropped state. Vault::open is the check: Argon2id over the wrong passphrase fails at AEAD decryption, with no comparison to shortcut. Rate-limited per minute so the KDF cannot be driven by a flood of guesses. 409 if already unlocked, 401 on failure |
| POST | /vault/lock |
✓ | Lock the vault: signals the SSE streams, unwires the connector chat loops, pauses the scheduler, clears the session and stream-ticket maps, joins every background task, and zeroizes the vault key. Idempotent — locking an already-locked daemon is a 200, so a panic-button UI never has to reason about current state. Served by the outer router, which has no AppState, so the bearer check is run by hand inside the handler rather than by require_auth |
springtaled is the MCP server (Streamable HTTP transport). Tool calls
dispatch through the same sentinel, approval gate, and executions recorder
as a rule-dispatched action — there is no separate path.
| Method | Path | Auth | Description |
|---|---|---|---|
| ANY | /mcp |
✓ | MCP Streamable HTTP endpoint |
Two layers run in front of it, outside-in: an Origin check
(require_local_origin) rejects any non-loopback origin per the MCP
transports spec's DNS-rebinding requirement, then require_auth checks the
bearer, and only then is any MCP framing parsed. A missing Origin header
is accepted (non-browser clients do not send one); anything non-loopback is
403. The Mcp-Session-Id header is never authentication.
┌────────────────────────────────────────────────────────────────┐
│ Middleware Stack │
│ │
│ 1. TraceLayer HTTP trace span │
│ 2. SetResponseHeaderLayer × 5 security headers (§4.1) │
│ 3. RequestBodyLimitLayer 1 MiB per request │
│ 4. HandleErrorLayer maps rate-limit err → 429 │
│ 5. BufferLayer (256) fronts the rate limiter │
│ 6. RateLimitLayer 100 req/s (configurable) │
│ 7. TimeoutLayer 30 s per request → 503 │
│ │
│ require_auth middleware Bearer header or stream ticket │
│ ValidatedPath extractor path segments ≤ 256 bytes │
│ │
└────────────────────────────────────────────────────────────────┘
Fig. 2. Applied to all routes.
Five headers are set on every response by SetResponseHeaderLayer:
| Header | Value |
|---|---|
X-Frame-Options |
DENY |
Content-Security-Policy |
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*; img-src 'self' data:; frame-ancestors 'none' |
X-Content-Type-Options |
nosniff |
Referrer-Policy |
no-referrer |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), accelerometer=(), gyroscope=() |
HSTS is deliberately omitted. RFC 6797 §8.1 requires that Strict-Transport-Security not be sent over plain HTTP, and springtaled binds 127.0.0.1 without TLS by default. An operator terminating TLS in front of the daemon (reverse proxy, mesh sidecar) should set HSTS at that layer.
All errors return JSON:
{
"error": "connector not found: connector-foo"
}TABLE I. STATUS CODES
| Code | Meaning |
|---|---|
| 200 | Success |
| 400 | Bad request (invalid JSON, missing fields, validation failure) |
| 401 | Unauthorized (missing or invalid bearer token) |
| 403 | Capability denied (manifest lacks required permission) |
| 404 | Not found |
| 409 | Conflict (name collision, toxic pair on install) |
| 413 | Payload too large (body > 1 MiB) |
| 422 | Unprocessable (JSON Schema validation failed) |
| 429 | Rate limited |
| 500 | Internal server error |
| 503 | Service unavailable (booting or degraded) |
All four SSE endpoints emit event: + data: lines. Payloads are JSON.
Emits every event logged to the events table, in order.
event: event
data: {"id":"...","connector":"connector-telegram","trigger_type":"message_received","timestamp":"2026-04-10T12:34:56Z"}
Emits CanvasUpdate deltas for dashboard rendering.
event: canvas
data: {"kind":"node_moved","id":"...","x":120,"y":80}
Broadcast semantics: slow consumers receive RecvError::Lagged(n) and must reconnect. The dashboard auto-refetches GET /canvas on reconnect.
Emits formation lifecycle, momentum transition, rally, and interference
events. Accepts an optional ?formation_id=... filter. See §3.4.
Emits the bot's in-app chat replies (§3.19). Each event's data is a
{"session": "...", "text": "..."} object. Same lagged-subscriber
semantics as the other broadcast streams.
- [1] Configuration: configuration.md
- [2] CLI commands: cli.md
- [3] Full architecture:
docs/arch/ARCHITECTURE.md§9 - [4] Security posture:
docs/arch/SECURITY.md§6 - [5] Cooperation framework:
docs/intended-arch/COOPERATION.md