HTTP API
The runner and dashboard HTTP surface — health, the internal worker endpoint, OAuth callbacks, and webhook ingress.
Nodal-Agents runs two HTTP servers: the runner (Hono, default :3001) which executes jobs, and the dashboard (Next.js, default :3000) which serves the UI and a few API routes. Most endpoints are internal — the web calls the runner, the runner calls itself. This page documents the surface that matters operationally.
Authentication model
The runner's auth depends on AUTH_MODE:
- local-trust (default, loopback) — no auth on the runner. Intended for single-user local use.
- local-auth / bearer-token — protected routes require
Authorization: Bearer <WORKER_SECRET>. The web sends this on every runner call. Inbearer-tokenmode a valid auth-provider session is also accepted (for external API clients).
WORKER_SECRET is generated by nodal-agents init and stored in ~/.nodalai/config.json. It is the runner's auth boundary — switching the network bind to LAN does not open the runner up, because these modes still require the secret.
Runner endpoints (:3001)
| Method & path | Auth | Purpose |
|---|---|---|
GET /api/health | public | Liveness/readiness probe. |
POST /api/worker | WORKER_SECRET (self) | Execute a job (the LLM loop). |
POST /api/agent | runner auth | Submit a task to an agent. |
POST /api/chat | runner auth | Chat-turn endpoint (dashboard chat). |
POST /api/chat/stream | runner auth | The same turn, streamed token by token. |
POST /api/approve | runner auth | Resolve a pending approval (resume the job). |
POST /api/cron | runner auth | Trigger a cron tick. |
GET /api/jobs/:jobId/file-diff | runner auth | The diff of one file a turn wrote. |
POST /api/code-task/doctor | runner auth | Probe whether a coding CLI is installed and signed in. |
POST /api/skills/install | WORKER_SECRET | Install a community skill. |
POST /api/skills/uninstall | WORKER_SECRET | Uninstall a community skill. |
POST /api/skills/update | WORKER_SECRET | Pull the latest version of an installed community skill. |
POST /api/skills/preview-update | WORKER_SECRET | Show what an update would change, without applying it. |
POST /api/skills/acknowledge-update | WORKER_SECRET | Mark an available update as seen. |
GET /api/whatsapp/pairing | runner auth | WhatsApp QR pairing status for an agent. |
POST /api/whatsapp/pairing | runner auth | Start WhatsApp QR pairing for an agent. |
POST /webhooks/:slug/:secret | slug + secret (in the path) | Fire a configured webhook trigger's job. |
The auth gate is a literal-path middleware, so /api/chat does not cover
/api/chat/stream: each path is listed on its own. The five skill routes check
WORKER_SECRET inside their own handlers instead.
"runner auth" = pass-through in local-trust, else WORKER_SECRET bearer (or a session in bearer-token mode).
GET /api/health
The cheapest possible probe — runs SELECT 1 against Postgres. Never throws.
// 200 when healthy, 503 when the DB is unreachable
{ "ok": true, "db": "ok", "llm": "unchecked" }The LLM is not pinged on every health check (too expensive), so llm is always "unchecked". nodal-agents up waits for this to return 200 before declaring the stack healthy.
POST /api/worker
The internal execution endpoint. Authenticated with the WORKER_SECRET bearer token (separate from the unified runner-auth gate — this is a runner-calls-itself path). The body is a single job id:
{ "jobId": "<uuid>" }It responds 202 Accepted immediately and runs the LLM loop in the background; the result is written to the database and the dashboard observes it. Bad secret → 403 invalid_worker_secret; bad body → 400.
GET /api/whatsapp/pairing and POST /api/whatsapp/pairing
Read and kick the runner's in-process WhatsApp manager for a single agent's pairing state. An untrusted bearer-token caller may only pair an agent inside its own entity.
GET ?agentId=<uuid>returns{ status, qr }.qr(the pairing QR payload) is present only whilestatusisqr_pending; it is masked tonullotherwise, even if the manager still holds a stale value internally, since a QR is a one-time linking secret.404 no_whatsapp_bindingwhen the agent has no WhatsApp binding currently tracked;503 whatsapp_manager_unavailableif the manager never started (e.g.WHATSAPP_BRIDGE_ENABLED=false).POST { agentId }idempotently ensures the pairing socket exists so a QR starts being generated, then responds202. Poll theGETroute afterward to retrieve it.
Dashboard endpoints (:3000)
| Method & path | Purpose |
|---|---|
GET /api/health | Web liveness — returns { "ok": true, "service": "nodalai-web" }. |
GET POST /api/auth/[...all] | better-auth catch-all (sign-up, sign-in, session, sign-out). Only active in local-auth mode. |
POST /api/oauth/[provider]/start | Begin an OAuth connector flow. |
GET /api/oauth/[provider]/callback | OAuth provider redirect target. |
OAuth flow
Connecting an OAuth provider (Google, Notion, Airtable, …) is a two-leg flow handled by the dashboard:
POST /api/oauth/{provider}/start— authenticated, takes the client id/secret (form-encoded). It generates PKCE + a CSRF state, signs the state into anHttpOnlycookie, and redirects the browser to the provider's authorization URL.GET /api/oauth/{provider}/callback— the provider redirects back here withcode+state. The handler verifies the CSRF state (constant-time) and the session, exchanges the code for tokens, fetches the account name, persists a credentials row, and redirects back to the dashboard.
{provider} is either a catalog connector slug (google-drive) or a credential type (google-oauth). The redirect URI registered with the provider must be {appUrl}/api/oauth/{provider}/callback.
Webhook ingress
POST /webhooks/:slug/:secret on the runner (:3001) is the live inbound entry point for the webhook triggers stored in the webhook_triggers table. A webhook trigger binds an agent + a task_template to a slug and a generated secret; each trigger gets its own full URL, e.g. http://<runner-host>:3001/webhooks/daily-report/ab12cd34… (composed by the dashboard's Automations page from the current hostname).
There is no shared base URL and no header-based auth: the path segments themselves are the credential.
- Auth is per-trigger, in the path: unknown slug, an inactive trigger, a missing stored secret, or a mismatched secret all return the identical generic
404(no signal about which check failed). Secret comparison is timing-safe. - Rate limit: 30 accepted fires per trigger per rolling hour. Over the limit returns
429 rate_limited, checked before the body is even parsed. - Body: JSON only, hard-capped at 1 MB, read as a bounded stream so a wrong or absent
Content-Lengthcan't smuggle an oversized body past the cap. Too large →413 payload_too_large; not valid JSON →400 invalid_request. - On success the route interpolates
{dot.path}placeholders in the trigger'stask_templateagainst the posted JSON body, wraps the result in an anti-injection envelope (the payload is framed as untrusted external data, never as owner instructions, and the agent's normal approval rules still apply), creates anagent_jobsrow withchannel: 'webhook', incrementstrigger_countandlast_triggered_aton the trigger, and returns202 { ok: true, jobId }immediately (the job itself runs in the background). - A trigger with no agent attached returns
400 trigger_has_no_agent; a job-creation failure returns500 job_creation_failed.