Nodal-Agents
Reference

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. In bearer-token mode 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 & pathAuthPurpose
GET /api/healthpublicLiveness/readiness probe.
POST /api/workerWORKER_SECRET (self)Execute a job (the LLM loop).
POST /api/agentrunner authSubmit a task to an agent.
POST /api/chatrunner authChat-turn endpoint (dashboard chat).
POST /api/chat/streamrunner authThe same turn, streamed token by token.
POST /api/approverunner authResolve a pending approval (resume the job).
POST /api/cronrunner authTrigger a cron tick.
GET /api/jobs/:jobId/file-diffrunner authThe diff of one file a turn wrote.
POST /api/code-task/doctorrunner authProbe whether a coding CLI is installed and signed in.
POST /api/skills/installWORKER_SECRETInstall a community skill.
POST /api/skills/uninstallWORKER_SECRETUninstall a community skill.
POST /api/skills/updateWORKER_SECRETPull the latest version of an installed community skill.
POST /api/skills/preview-updateWORKER_SECRETShow what an update would change, without applying it.
POST /api/skills/acknowledge-updateWORKER_SECRETMark an available update as seen.
GET /api/whatsapp/pairingrunner authWhatsApp QR pairing status for an agent.
POST /api/whatsapp/pairingrunner authStart WhatsApp QR pairing for an agent.
POST /webhooks/:slug/:secretslug + 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 while status is qr_pending; it is masked to null otherwise, even if the manager still holds a stale value internally, since a QR is a one-time linking secret. 404 no_whatsapp_binding when the agent has no WhatsApp binding currently tracked; 503 whatsapp_manager_unavailable if 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 responds 202. Poll the GET route afterward to retrieve it.

Dashboard endpoints (:3000)

Method & pathPurpose
GET /api/healthWeb 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]/startBegin an OAuth connector flow.
GET /api/oauth/[provider]/callbackOAuth provider redirect target.

OAuth flow

Connecting an OAuth provider (Google, Notion, Airtable, …) is a two-leg flow handled by the dashboard:

  1. POST /api/oauth/{provider}/start — authenticated, takes the client id/secret (form-encoded). It generates PKCE + a CSRF state, signs the state into an HttpOnly cookie, and redirects the browser to the provider's authorization URL.
  2. GET /api/oauth/{provider}/callback — the provider redirects back here with code + 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-Length can'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's task_template against 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 an agent_jobs row with channel: 'webhook', increments trigger_count and last_triggered_at on the trigger, and returns 202 { 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 returns 500 job_creation_failed.

On this page