Skip to content

Server API Reference ​

aerini-server api exposes a REST API for every workflow it manages: CRUD, on-demand runs, dry-run validation, the node catalog, the scheduler, live memory and performance data, credentials, plugins, and token administration, plus a Server-Sent Events stream for run status. This page is an exhaustive route reference. For how to get the server running in the first place, see Server Deployment; for the equivalent aerini-server CLI flags and control commands, see Server CLI Reference; for the security reasoning behind scopes, ACLs, and the SSRF warning, see Security.

Authentication and scopes ​

Every route below requires an Authorization: Bearer <token> header, except GET /api/health, GET /aerini-widget.js, POST /api/widget/:workflow_id/trigger, and POST /api/widget/:workflow_id/mint-token (see Widget trigger). A missing or invalid token returns 401 Unauthorized with {"error": "Invalid or missing token"}.

Each token carries one or more scopes: read, write, admin. A route's required scope is checked against the token's scope list; admin satisfies any check, so an admin token never needs read or write listed separately. Failing a scope check returns 403 Forbidden with {"error": "<scope> scope required"} (token management routes use {"error": "admin scope required for token management"} instead).

Per-token workflow ACL ​

Beyond scope, a token can be restricted to a specific set of workflow IDs. GET /api/tokens/:id/workflows lists a token's current grants; POST and DELETE on /api/tokens/:id/workflows/:workflow_id add or remove one. POST /api/tokens can also set the grants at creation with workflow_ids. A token with zero grants is unrestricted, not blocked: the ACL only starts filtering once at least one workflow is explicitly granted. Because removing a token's only grant would silently make it unrestricted, that request is refused with 409. Admin tokens are always unrestricted, so granting a workflow to one is refused with 400. To cut a token off entirely, revoke the token itself.

A restricted token is confined to its granted workflows on every route that addresses workflows:

  • Pre-check, 403 with {"error": "token ACL does not permit access to this workflow"} before any work is done: GET|DELETE /api/workflows/:id, POST /api/workflows/:id/run, POST /api/workflows (the workflow's own id must be granted, so a restricted token cannot create a new workflow or overwrite another one), POST /api/scheduler/:id/start, POST /api/scheduler/:id/stop, GET|DELETE /api/performance/reports, and GET /api/events?workflow_id=. The answer depends only on the token's own grants, never on whether the workflow exists, so it reveals nothing about other workflows.
  • Filtered lists: GET /api/workflows, GET /api/scheduler, GET /api/memory, GET /api/performance/live, and GET /api/events without a workflow_id return only what the token may see. On the two paginated lists, filtering happens before pagination, so total and the page reflect only the token's own workflows.
  • Fetch-then-check, 404: GET|DELETE /api/performance/reports/:run_id return a plain 404 for a run whose workflow is outside the grants, identical to a run that does not exist.
  • Not tied to a workflow: GET /api/nodes and POST /api/validate-workflow address no workflow and read nothing stored, so a restricted token may use them (read scope still applies).
  • Server-wide routes, 403: GET, POST and DELETE on /api/credentials belong to no single workflow, so a restricted token is refused with {"error": "token is restricted to specific workflows and cannot use server-wide routes"}. Scope still applies on top of the ACL.

Conventions ​

Errors. Failures return a JSON body of the form {"error": "<message>"} (some also add extra fields, noted per route). Success on a mutation is usually {"ok": true} plus any fields specific to that route. A failed store, database or background-task call returns 500 with {"error": "internal server error"}; the cause is written to the server log, not the response.

Pagination. GET /api/workflows and GET /api/scheduler share one shape: limit (default 100, capped at 500) and offset (default 0) as query parameters, and a response body of {"items": [...], "total": <n>, "limit": <n>, "offset": <n>}. GET /api/performance/reports uses a similar but distinct shape: limit (default 100, clamped to 1..=1000 server-side) and offset, with a response of {"items": [...], "limit": <n>, "offset": <n>, "has_more": <bool>} (no total).

Concurrency and limits. Requests are capped at 300 per client IP per 60-second window; over that returns 429 Too Many Requests with {"error": "rate limit exceeded"} and a Retry-After: 60 header. The widget routes' own stricter limits return the same response. Both carry the usual CORS headers for an allowed origin, so a browser client can read the 429 status and body; preflight (OPTIONS) requests are answered by the CORS layer before the limiter and don't count toward it; browsers cache a preflight answer for an hour. IPv6 clients are limited per /64 rather than per address, since one subscriber can send from every address in its /64, and an IPv4-mapped IPv6 address counts as its IPv4 address. The client IP is the socket's own address unless --trusted-proxy-count is set, in which case that many hops are trusted out of X-Forwarded-For, counted from the right across all of its header lines. Each limiter tracks at most 50,000 clients (or widget workflow IDs) at once: past that, an unseen one isn't limited until stale entries expire, and tracked ones stay limited. Request bodies are capped at 5 MiB, and at 1 MiB on the two public widget routes. GET /api/events connections are capped at 64 concurrent, server-wide; a connection past that limit gets 429 with {"error": "too many active SSE connections"}. POST /api/workflows/:id/run draws from a global run semaphore sized by --max-concurrent-runs (default 10); a request that can't get a slot within --max-queue-wait-secs (default 30) gets 503 Service Unavailable with a Retry-After header, and a request for a workflow that's already running waits up to 5 seconds for that workflow's own lock before returning 429 with {"error": "workflow already running"}.

CORS. Cross-origin requests are allowed from localhost/127.0.0.1/[::1] on any port plus any origin passed via --allow-origin, for GET, POST, and DELETE, with Authorization, Content-Type, and If-Match as allowed request headers and ETag and Retry-After exposed to the browser. POST /api/widget/:workflow_id/mint-token is the one exception: no origin is ever allowed on it, --allow-origin included, because it is meant to be called from your own server, not a browser (see Embeddable Chat Widget).

Response headers. Every response carries X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy: camera=(), microphone=(), geolocation=(), and Strict-Transport-Security. JSON responses also get Content-Security-Policy: default-src 'none' and a no-store Cache-Control; GET /aerini-widget.js gets a 5-minute Cache-Control instead, since it's a static asset with no content hash to bust on deploy.

Health ​

MethodPathScope
GET/api/healthnone

Unauthenticated. Response: {"status": "ok", "version": "<engine version>", "node_bundled": {"status": "ok", "version": "<node version>"}}, or node_bundled: {"status": "error", "message": "bundled Node.js check failed; see the server log"} if the bundled Node.js runtime failed its own check (the cause is logged once per failure streak, not per request). The outer status is always "ok" if the server is up enough to answer at all.

Widget trigger ​

MethodPathScope
GET/aerini-widget.jsnone
POST/api/widget/:workflow_id/triggernone
POST/api/widget/:workflow_id/mint-tokennone

None of the three routes take a Bearer token. GET /aerini-widget.js serves the embeddable chat widget's JS as a static asset. POST /api/widget/:workflow_id/trigger is a public gateway that relays a browser POST to that workflow's already-running Webhook listener; see Widget Embedding for how a page embeds it, and Per-token workflow ACL is not relevant here since there is no token at all on this route. Body: {"secret": "<webhook secret>", "body": <any JSON, forwarded verbatim>}. The route checks secret (the Webhook node's raw secret, or a signed token minted from it) in constant time and answers 401 if it is wrong, or 400 if the Webhook node has no secret configured; only then is it forwarded as x-webhook-secret to the workflow's Webhook listener, which checks it again.

On success: 200 with {"ok": true, "output_node_id": "<node id or null>"}. The actual workflow output is not returned here (the Webhook listener only acks receipt); it arrives later as a scheduler-status event over GET /api/events. Failure cases: 404 if the workflow isn't scheduled, 409 if it's scheduled but not currently active, 400 if its trigger isn't a Webhook trigger or the secret holds characters an HTTP header can't carry (anything but visible ASCII and space), 502 if the listener can't be reached, and the upstream listener's own status code (with a translated, non-revealing hint) if it rejects the request, for 401 (secret mismatch), 404/405 (path or method mismatch, the widget always sends POST), and 413 (body too large).

POST /api/widget/:workflow_id/mint-token exchanges the Webhook node's raw secret for a short-lived signed token, meant to be called from the embedding page's own backend at render time so the raw secret never ships to a browser; see Widget Embedding for the full pattern. Body: {"secret": "<webhook secret>", "ttl_secs": <optional, default 300>}. secret must be the Webhook node's raw configured secret, not a previously-minted token, and is checked with the same constant-time comparison as trigger's. ttl_secs is clamped server-side to [1, 86400] (24 hours) regardless of what's requested.

On success: 200 with {"token": "<signed token>", "expires_at": "<RFC 3339 timestamp>", "ttl_secs": <clamped value>}. Failure cases: the same 404/409/400 as trigger for an unscheduled, inactive, or non-Webhook workflow; 400 if the Webhook node has no secret configured to mint from; 401 if secret doesn't match. This route sits behind the same rate limiter as trigger (see Security tradeoffs for the actual limits), since a wrong guess here is exactly as much of a secret-guessing oracle as trigger is.

Workflows ​

MethodPathScope
GET/api/workflowsread
POST/api/workflowswrite
GET/api/workflows/:idread
DELETE/api/workflows/:idwrite
POST/api/workflows/:id/runwrite

All five enforce the per-token workflow ACL: a token restricted to other workflows gets 403 on a workflow outside its grants, and GET /api/workflows lists only its granted workflows.

GET /api/workflows takes the shared pagination params (see Conventions). Each item is {"id", "name", "updated_at", "tags": [...], "collection_id"} (a summary, not the full workflow).

POST /api/workflows body: {"workflow_json": "<the workflow as a JSON string>"}. An optional If-Match: "<row_version>" request header enables optimistic-concurrency protection: omit it for an unconditional last-write-wins save; send back the row_version from a prior ETag to get 409 Conflict ({"error": "workflow was modified since you last loaded it", "current_row_version": <n>}) on a stale write, or 412 Precondition Failed if the workflow no longer exists at all. A malformed If-Match value (not a bare or quoted integer) is 400. On success, 200 OK either way, whether the workflow was newly created or updated. Response and ETag both carry the new row_version: {"ok": true, "row_version": <n>}.

GET /api/workflows/:id returns 200 with an ETag: "<row_version>" header and a body that is the workflow's pretty-printed JSON encoded as a JSON string, not a JSON object: the body is "{\n \"id\": ...\n}", so a client needs to parse it twice (once for the HTTP body, once for the string it contains) to get a usable object. 404 if the ID doesn't exist.

DELETE /api/workflows/:id stops any active scheduled job for the workflow first (best effort), then deletes its run history, scheduled-job row, and the workflow itself. 200 with {"ok": true}.

POST /api/workflows/:id/run body: {"initial_variables": {...}} (optional, defaults to empty). Runs synchronously and returns the full result once execution finishes: 200 with {"execution_id", "workflow_id", "success", "node_outputs": {...}, "logs": [...], "error": "<string or null>", "validation_errors": [...]}. A workflow that fails at runtime still returns 200 with success: false and error set; 500 is reserved for the request itself failing (a DB error, an executor panic), not for the workflow's own logic failing. 404 if the workflow doesn't exist. Note that a caller with the admin scope who runs a workflow this way passes that admin privilege into the run, so any Database node in that workflow gets allow_raw_sql for that execution. If the client disconnects before the run finishes, the run is cancelled.

Node catalog and validation ​

MethodPathScope
GET/api/nodesread
POST/api/validate-workflowread

Both are read-only: nothing is saved or run, and neither depends on a workflow ID, so the per-token workflow ACL does not restrict them.

GET /api/nodes returns 200 with {"engine_version": "<version>", "nodes": [...]}. nodes is the live registry (built-in nodes plus any loaded plugins) sorted by type_id, so two calls return the same order. Each entry has type_id, display_name, node_type, version, description, author, input_schema (JSON Schema Draft 7 for the node's config fields), output_schema, ports (inputs and outputs, each with an id, and arity on an input that accepts several wires), dynamic_ports (true when the ports depend on the config), is_plugin, trigger_capable, and enabled. enabled is false for a Shell Command, Code or Database node when the server was started without --allow-shell, --allow-code or --allow-database: the node can be saved in a workflow but fails when a run reaches it. The plugin icon markup is left out. The same data for the built-in nodes, for a version without a running server, is in the generated Node Catalog and node-schemas.json.

POST /api/validate-workflow body: {"workflow_json": "<the workflow as a JSON string>"}, the same envelope as POST /api/workflows. It checks the workflow against this server's live node registry and always answers 200 when the envelope is well formed, including for an invalid workflow; the answer is in valid. A body without workflow_json is a 4xx from the JSON parser. The response is:

json
{
  "valid": false,
  "best_effort": true,
  "errors":   [{"check": "node_types", "message": "...", "node_id": "n2"}],
  "warnings": [{"check": "ports", "message": "...", "node_id": "n1", "edge_id": "e1"}],
  "checks_run": ["duplicate_ids", "node_types", "graph", "port_arity", "ports", "triggers", "required_config"]
}

valid is true exactly when errors is empty. node_id and edge_id appear only when a finding points at one. The checks:

checkReported asWhat it finds
parseerrorThe JSON does not parse as a workflow (the only check run in that case)
duplicate_idserror (nodes), warning (edges)Two nodes, or two edges, share an id
node_typeserror (warning on a disabled node)A node_type_id that is not registered on this server
grapherrorAn edge to a node that does not exist, a cycle, no entry node, or a node no entry node reaches; the first one found
port_arityerrorTwo wires into an input port that accepts one; the first one found
portswarningAn edge that leaves or enters a port the node does not declare. The run routes by from_port alone, so such an edge carries nothing
triggerswarningNo enabled trigger node, so the workflow can only be run on demand
required_configwarningA field the node type lists as required is empty in its config and not filled by a credential. An upstream node may still supply it at run time

This is a best-effort check, not a promise that the run will succeed. It does not type-check config values (many are {{expressions}}), does not repeat per-node rules such as the Database node's db_type-dependent fields, and cannot see credentials, network access or sandbox limits. valid: true means no known structural problem. Findings never include config values, only field names.

Scheduler ​

MethodPathScope
GET/api/schedulerread
POST/api/scheduler/:id/startwrite
POST/api/scheduler/:id/stopwrite

start and stop also enforce the per-token workflow ACL; a write-scoped token restricted to other workflows gets 403 on a workflow outside its grants. This is the REST-level equivalent of the CLI's start/stop/restart commands (see Server CLI Reference); a CLI call needing both read (to resolve a workflow name via list) and write (for the actual start/stop call) is two REST calls, each individually scoped as shown here, not one route requiring both scopes at once.

GET /api/scheduler takes the shared pagination params. For an ACL-restricted token, filtering happens before pagination, so total and the returned page only ever reflect workflows that token can see. Each item is a ScheduledJobRow: {"workflow_id", "workflow_name", "trigger_kind": "<JSON-encoded trigger, itself a string>", "status": "active"|"paused"|"done"|"error"|"stopped", "always_on", "run_count", "last_run_at", "next_run_at", "last_error", "created_at"}. trigger_kind is JSON-encoded twice the same way a workflow body is on GET /api/workflows/:id; a Webhook trigger's secret field inside it is always replaced with the literal string <redacted>, never the real value.

POST /api/scheduler/:id/start body: {"always_on": <bool or omitted>, "port_override": <u16 or omitted>}. Leaving always_on out keeps the workflow's stored setting (false for a workflow that has never been started); true or false sets it. 200 with {"ok": true} on success, 400 with a readable message (e.g. workflow not found, a port conflict, or workflow is not schedulable (no Schedule, Webhook, or trigger-plugin trigger)) if the scheduler rejects the start. Any other start failure returns 500 with {"error": "internal server error"}.

POST /api/scheduler/:id/stop no body. 200 with {"ok": true}.

Memory ​

MethodPathScope
GET/api/memoryread

Enforces the per-token workflow ACL. No pagination; returns every currently-live run as a flat JSON array (not wrapped in items), each entry a RunBreakdown: {"workflow_id", "started_at_ms", "live_bytes": <i64, executor overhead only, excludes node bytes>, "nodes": [{"node_id", "node_type_id", "started_at_ms", "live_bytes"}, ...]}. Empty array if nothing is currently running (or nothing the caller's ACL permits).

Performance ​

MethodPathScope
GET/api/performance/liveread
GET/api/performance/reportsread
DELETE/api/performance/reportswrite
GET/api/performance/reports/:run_idread
DELETE/api/performance/reports/:run_idwrite

GET /api/performance/live enforces the ACL the same way as /api/memory: a flat array, no pagination, filtered before returning. Each entry is a PerformanceReport: {"workflow_id", "status": "running"|"success"|"failed", "started_at_ms", "finished_at_ms", "duration_ms", "sampling_interval_ms", "baseline_bytes", "final_bytes", "peak_bytes", "peak_at_ms", "minimum_bytes", "average_bytes", "delta_bytes": <i64, can be negative>, "sample_count", "history": [{"at_ms", "bytes"}, ...]}. For a still-running workflow, finished_at_ms is the timestamp this particular reading was taken, not a real completion time.

GET|DELETE /api/performance/reports (list and clear all reports for one workflow) both require a ?workflow_id= query parameter; a request missing it is rejected with 400 before the handler runs. Both enforce the ACL pre-check (403 on a workflow outside the caller's grants). GET also takes limit (default 100, clamped 1..=1000) and offset; response is {"items": [<PerformanceReportRecord>, ...], "limit", "offset", "has_more"}. Each item is a PerformanceReport (fields as above) flattened together with an "id" field (the run ID), not nested under a report key despite the underlying Rust type's name. DELETE clears every stored report for that workflow: 200 with {"ok": true}.

GET|DELETE /api/performance/reports/:run_id (a single report by run ID) check scope first, then fetch the row and apply the ACL as a fetch-then-check: a row that exists but belongs to a workflow outside the caller's grants returns a plain 404, identical to a row that doesn't exist at all, never 403. GET returns the same flattened PerformanceReportRecord shape as the list route. DELETE returns 200 with {"ok": true}; deleting an already-deleted run_id (a race with another caller) is treated as success, not an error.

Credentials ​

MethodPathScope
GET/api/credentialsread
POST/api/credentialswrite
DELETE/api/credentials/:idwrite

This is the api-mode credential store described in Credentials, separate from the desktop app's own store.

A token restricted to specific workflows gets 403 on all three routes (see Per-token workflow ACL).

GET /api/credentials returns a flat array of {"id", "name", "cred_type"}. Never includes the secret value, and never includes the optional provider/model/base_url metadata a credential can carry, since the desktop-side list response type this route reuses doesn't have those fields.

POST /api/credentials body: {"id", "name", "value", "cred_type", "update"} (cred_type defaults to "api_key", update to false). provider, model, and base_url are not accepted by this route: it always stores a credential with those three set to none, even though the underlying store supports them (the desktop app's own credential UI can set them; this REST route currently can't). 200 with {"ok": true} on success. 409 when the id already exists and update is not true; with "update": true the credential is replaced, its creation time is kept, and an empty value keeps the stored secret (404 if the id does not exist). 400 for an id that is not 1-128 letters, digits, _ or -, a blank or control-character name or one over 256 characters, an empty value on create or one over 65,536 bytes, or a cred_type other than api_key, bearer, basic, oauth, other.

DELETE /api/credentials/:id 200 with {"ok": true}; 404 if the id does not exist.

Plugins ​

MethodPathScope
POST/api/plugins/reloadadmin
GET/api/plugins/load-reportadmin

Both admin-scoped: a plugin is code that runs for every caller on the server, not just the caller's own resources, so this is a higher trust bar than write.

POST /api/plugins/reload rebuilds the node registry from scratch (built-ins plus every .wasm in --plugin-dir) and swaps it in atomically; a run already in progress keeps executing against the registry snapshot it started with. 400 with {"error": "no --plugin-dir configured for this server: nothing to reload"} if the server wasn't started with one. On success, 200 with the resulting PluginLoadReport: {"loaded": [...], "builtin_rejected": [...], "plugin_collisions": [...]}, each a flat array of node type_id strings (loaded includes the winner of any collision; builtin_rejected lists ones that collided with a built-in node and lost outright; plugin_collisions lists ones two or more plugin files both claimed).

GET /api/plugins/load-report returns the same PluginLoadReport shape, from the most recent load (server startup, or the last /api/plugins/reload call), without triggering a new scan.

Tokens ​

MethodPathScope
GET/api/tokensadmin
POST/api/tokensadmin
DELETE/api/tokens/:idadmin
GET/api/tokens/:id/workflowsadmin
POST/api/tokens/:id/workflows/:workflow_idadmin
DELETE/api/tokens/:id/workflows/:workflow_idadmin

All six require admin; this is the REST equivalent of the CLI's tokens subcommands (see Server CLI Reference).

GET /api/tokens returns a flat array of {"token_id", "label", "scopes": [...], "created_at", "revoked_at", "expires_at"}. Never includes the token's own secret value; that only exists at creation time.

POST /api/tokens body: {"label": "<1-256 chars>", "scopes": [...], "expires_in_secs": <u64 or omitted>, "workflow_ids": [...]} (scopes defaults to ["read", "write"] if omitted; it must not be empty, and each entry must be read, write, or admin, anything else is 400; a blank label is 400; expires_in_secs of 0, or too large to represent as a date, is 400; admin together with a non-empty workflow_ids is 400, because admin tokens ignore the ACL; workflow_ids allows at most 500 entries, each 1–256 characters, otherwise 400; workflow_ids, if non-empty, restricts the token to those workflows in the same call, see Per-token workflow ACL). On success, 201 Created with {"token": "<raw token, shown once>", "token_id", "label", "scopes", "expires_in_secs", "workflow_ids", "note": "Save this token — it will not be shown again."}.

DELETE /api/tokens/:id 200 with {"ok": true}; revoking an already-revoked token is also 200, an unknown token ID is 404. A token cannot revoke itself: revoking the same token ID present in the caller's own Authorization header returns 400 with {"error": "Cannot revoke the token you are currently using"}.

GET /api/tokens/:id/workflows lists the target token's ACL grants: {"token_id", "workflow_ids": [...], "note": "<one of two fixed strings depending on whether the list is empty>"}. An empty list means that token is unrestricted everywhere the ACL applies (see Per-token workflow ACL), not that it has no access.

POST /api/tokens/:id/workflows/:workflow_id grants the target token access to one workflow. 201 with {"ok": true, "token_id", "workflow_id", "note": "..."}. 404 for an unknown or revoked token; 400 when the target token has the admin scope or the workflow ID is blank or over 256 characters.

DELETE /api/tokens/:id/workflows/:workflow_id revokes that grant. 200 with {"ok": true}, including when the token does not hold that grant. When it is the token's only grant the request is refused with 409 and nothing is removed, since the token would become unrestricted; revoke the token to cut it off, or create a new token without workflow_ids for an unrestricted one.

Events (SSE) ​

MethodPathScope
GET/api/eventsread

A long-lived text/event-stream connection, capped at 64 concurrent connections server-wide (see Conventions). Enforces the per-token workflow ACL; an optional ?workflow_id= query parameter narrows the connection to one workflow's events, and is rejected with 403 if the caller's ACL doesn't include that workflow. A connection narrowed by the ACL or by workflow_id receives only events that name an allowed workflow; server-wide events such as scheduler-ready are not sent to it. Each message's data field is a JSON object of the form {"event": "<type>", "payload": {...}}. The server also sends a keep-alive ping every 30 seconds on an otherwise-idle connection.

When the server receives a shutdown signal (SIGINT or SIGTERM) it ends every open event stream, so clients see an ordinary end-of-stream rather than a dropped connection. A browser EventSource will try to reconnect on its own; those attempts fail until the server is running again. Events are not numbered or replayed, so anything emitted while a client is disconnected is not delivered after it reconnects. Connections other than event streams get up to 10 seconds to finish before the server closes them.

Six event types are emitted:

EventPayload fields
node-statusworkflow_id, node_id, status (running, success, error, or skipped)
scheduler-statusworkflow_id, workflow_name, status (running, waiting, done, error, or stopped), run_count, last_run_at, next_run_at, last_error, trigger_type (interval, cron, once, webhook, manual, or plugin; null if unknown), last_result
scheduler-errorworkflow_id, message
scheduler-warningworkflow_id, message
scheduler-skipworkflow_id, reason
scheduler-readyjob_count, timestamp (no workflow_id; sent once at scheduler startup and forwarded to every connection regardless of ACL, the one event type an ACL filter can't narrow)

scheduler-status is what a widget or dashboard should watch for a workflow triggered through POST /api/widget/:workflow_id/trigger, since that route's own response never carries the run's actual output.

See also ​

  • Server Deployment, for getting --api mode running in the first place
  • Server CLI Reference, for the aerini-server flags and control commands that sit on top of these same routes
  • Widget Embedding, for the browser-side half of the widget trigger route
  • Credentials, for how this store differs from the desktop app's own
  • Security, for the reasoning behind scopes, the ACL model, and the SSRF warning this server logs on startup