Robutler
Api

All routes are scoped to a single agent (/api/agents/<idOrUsername>/...). Auth is owner session OR portal_token (RS256 JWT) unless noted otherwise.

List functions

GET /api/agents/:id/functions

Returns the agent's declared functions enriched with usedBy (which skills consume each function).

{
  "functions": [
    {
      "name": "stripeHandler",
      "declaration": { "contentId": "ctn_abc", "runtime": "js-v1", "permissions": { ... } },
      "usedBy": [{ "skill": "custom_http", "entryId": "stripe_webhook", "description": "POST /webhooks/stripe" }]
    }
  ]
}

Declare or update

POST /api/agents/:id/functions

Body: { name, manifest, source? }. Validates the manifest, stores the function under agent_configs.functions[name], and writes an audit row to function_invocations (source_skill = authoring).

Returns { ok: true, name, requiresUserAction?: [...] }requiresUserAction is non-empty when the manifest declares secret bindings the owner hasn't set yet.

Remove

DELETE /api/agents/:id/functions/:name

Removes the entry and detaches all consumer references. Audit row recorded.

Validate

POST /api/agents/:id/functions/:name/validate

Body: { manifest, source? }. Returns { ok, errors[], warnings[] }. Counts against the validation quota bucket; runtime-side validation is forwarded to the executor /validate endpoint when WEBAGENTS_EXECUTOR_URL is set.

Manual invoke

POST /api/agents/:id/functions/:name/invoke

Headers: Idempotency-Key (24h Redis dedupe). Body shape depends on the consumer:

ConsumerBody
custom_tools{ args: <parameter-schema-validated payload> }
custom_http{ method, path?, query?, headers?, body? }
cron (replay){}

Counts against quotas / billing same as any other invocation.

Invocation history

GET /api/agents/:id/functions/:name/invocations?limit=50&cursor=<iso>

Paginated by started_at desc; rows from function_invocations.

Set secret

POST /api/agents/:id/functions/:name/secret

Body: { binding, value }. Owner-session-authenticated only. Stores the value as JWE in memory(serverEncrypted=true, namespace='fn-secret:<name>'). The function reads it via ctx.secrets.get('<binding>').

DELETE /api/agents/:id/functions/:name/secret?binding=<name>

Removes the stored secret value.

Auto-generated OpenAPI

GET /api/agents/:id/functions/openapi.json

OpenAPI 3.1 spec derived from agent_configs.functions[*].parameters plus the active custom_tools / custom_http skill consumers, plus the manual-invoke endpoints.

Auth headers

SurfaceHeader(s)
Owner sessionCookie-based session, no extra headers
Portal tokenAuthorization: Bearer <RS256 JWT>
Factory / host editFunction-Authoring-Surface: factory | host | ui | cli

The portal validates the surface header against the calling agent id (host-edit can't edit other agents).

Runtime: js-v1 host API surface

User-authored functions run inside a per-tenant V8 isolate (isolated-vm) and reach the platform exclusively through the ctx argument passed to handler(ctx). Egress (ctx.fetch) executes in the executor's worker thread; every other stateful API round-trips to the portal's /api/internal/fn-host endpoint, authed with a 60-second RS256 JWT (typ: 'fn-invocation') whose claims pin agent / function / invocation / permissions / folder bindings (see ADR-0009).

Permission shapes live under manifest.permissions:

{
  "permissions": {
    "fetch":   ["https://api.example.com", "*.openai.com", "*"],   // allowlist
    "secrets": ["STRIPE_KEY", "write"],                            // names + "write" sentinel
    "kv":      "ro" | "rw" | "none",                               // mode (NOT array)
    "content": { "read": true, "write": false },
    "folders": [{ "alias": "uploads", "contentId": "ctn_…", "permissions": "rw" }],
    "portal":  ["payment.lock", "payment.settle", "callTool"],     // allowlist
    "rawBody": false,
    "selfRecursion": false
  }
}
ctx APIPermission requiredNotes
ctx.fetch(url, init?)permissions.fetch allowlist (*, exact URL, *.host)Worker-direct (no host bridge). Tracks ingressBytes/egressBytes. Non-http(s) protocols rejected.
ctx.secrets.get(name)namepermissions.secretsReturns plaintext (decryption stays in the portal).
ctx.secrets.put(name, v)permissions.secrets includes "write" AND nameEncrypted at rest (memory.serverEncrypted=true, namespace fn-secret:<fn>).
ctx.secrets.list()permissions.secrets non-emptyReturns names ∈ allowlist that exist in storage.
ctx.kv.get(key)permissions.kv{ ro, rw }Namespace fn:<fn> per agent.
ctx.kv.put(k, v, opts?)permissions.kv = rwCounts against per-invocation kvPutBytes quota (default 10 MB).
ctx.kv.delete(k)permissions.kv = rw
ctx.kv.list(prefix?, …)permissions.kv{ ro, rw }Cursor-paginated.
ctx.content.get(id)permissions.content.read = trueReturns { id, mimeType, displayName, size, arrayBuffer() }. Body capped by per-invocation ingress quota.
ctx.content.put(item)permissions.content.write = trueCounts against per-invocation contentWrites quota (default 5).
ctx.folders[alias].list()binding ∈ permissions.foldersToken-frozen at envelope build — renamed bindings can't escalate scope mid-flight.
ctx.folders[alias].read(name)binding ∈ permissions.folders
ctx.folders[alias].write(name, body)binding permissions: "rw"Counts against contentWrites quota.
ctx.fn.invoke(name, args)sibling fn declared on same agentChain depth ≤ 4 (matches FunctionRuntimeSkillConfig.maxChainDepth); cycles detected.
ctx.fn.list()noneReturns sibling fn names.
ctx.portal.<method>(...)methodpermissions.portalMethods: verifyToken, verifyHmac, lookupAgent, callTool, getOwner, notifyOwner, signContentUrl, payment.{lock,settle,release}.
ctx.log.{debug,info,warn,error}(...)noneBuffered host-side, capped at 64 KB per invocation; returned in ExecutorResponse.logs.
ctx.emit(event, payload?)noneSurfaced to Server-Sent Events stream when the calling skill subscribes.
ctx.request.rawBodypermissions.rawBody = trueUint8Array view; only populated when the caller actually carries a body.

Runtime sandbox details

  • Sandbox: bare V8 isolate (isolated-vm) on Node 20 LTS. Blocked: process, Buffer, require, fs, eval (throws EVAL_DENIED), Function constructor (throws FUNCTION_DENIED).
  • Available globals (web platform subset): URL, URLSearchParams, atob, btoa, JSON, Math, Date, Promise, Map/Set/WeakMap/WeakSet, RegExp, Symbol, Proxy, Reflect, Intl, console, TextEncoder/TextDecoder, structuredClone, crypto.{randomUUID, getRandomValues, subtle} (digest/sign/verify/encrypt/decrypt/importKey).
  • fetch is also installed as a top-level alias of ctx.fetch (same allowlist enforcement).
  • npm packages, Node-only modules, and curated host libs are not available in v1; bundling is tracked for v2.
  • Entrypoint: must export an async handler(ctx)export default async function handler(ctx) (preferred), export async function handler(ctx), or module.exports = async (ctx) => …. Source ≤ 16 KB UTF-8 (inline) or 64 KB base64 (inlineB64); larger source must move to a content row.
  • Limits: wallMs default 10 s (max 30 s), memoryMb default 256 (max 512), enforced by an isolated-vm memory cap and a host-side wall-clock watchdog that disposes the isolate on overshoot.
  • Error codes: WALL_TIMEOUT, MEMORY_LIMIT_EXCEEDED, EVAL_DENIED, FUNCTION_DENIED, FETCH_FORBIDDEN, HOST_QUOTA_EXCEEDED, PERMISSION_DENIED, HOST_BRIDGE_ERROR, JS_NO_HANDLER, JS_RESULT_NOT_SERIALIZABLE, JS_RUNTIME_ERROR.
  • Host-bridge quotas (per invocation, Redis-backed): callsTotal ≤ 100, kvPutBytes ≤ 10 MB, contentWrites ≤ 5. Breach surfaces as HTTP 409 HOST_QUOTA_EXCEEDED to the executor and HOST_QUOTA_EXCEEDED to the function.

python-pyodide-v1 is deferred (ADR-0008); manifests pinning it fail validation with RUNTIME_DISABLED. wasm-v1 is reserved for v2.

On this page