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/functionsReturns 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/functionsBody: { 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/:nameRemoves the entry and detaches all consumer references. Audit row recorded.
Validate
POST /api/agents/:id/functions/:name/validateBody: { 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/invokeHeaders: Idempotency-Key (24h Redis dedupe). Body shape depends on the consumer:
| Consumer | Body |
|---|---|
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/secretBody: { 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.jsonOpenAPI 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
| Surface | Header(s) |
|---|---|
| Owner session | Cookie-based session, no extra headers |
| Portal token | Authorization: Bearer <RS256 JWT> |
| Factory / host edit | Function-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 API | Permission required | Notes |
|---|---|---|
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) | name ∈ permissions.secrets | Returns plaintext (decryption stays in the portal). |
ctx.secrets.put(name, v) | permissions.secrets includes "write" AND name | Encrypted at rest (memory.serverEncrypted=true, namespace fn-secret:<fn>). |
ctx.secrets.list() | permissions.secrets non-empty | Returns 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 = rw | Counts 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 = true | Returns { id, mimeType, displayName, size, arrayBuffer() }. Body capped by per-invocation ingress quota. |
ctx.content.put(item) | permissions.content.write = true | Counts against per-invocation contentWrites quota (default 5). |
ctx.folders[alias].list() | binding ∈ permissions.folders | Token-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 agent | Chain depth ≤ 4 (matches FunctionRuntimeSkillConfig.maxChainDepth); cycles detected. |
ctx.fn.list() | none | Returns sibling fn names. |
ctx.portal.<method>(...) | method ∈ permissions.portal | Methods: verifyToken, verifyHmac, lookupAgent, callTool, getOwner, notifyOwner, signContentUrl, payment.{lock,settle,release}. |
ctx.log.{debug,info,warn,error}(...) | none | Buffered host-side, capped at 64 KB per invocation; returned in ExecutorResponse.logs. |
ctx.emit(event, payload?) | none | Surfaced to Server-Sent Events stream when the calling skill subscribes. |
ctx.request.rawBody | permissions.rawBody = true | Uint8Array 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(throwsEVAL_DENIED),Functionconstructor (throwsFUNCTION_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). fetchis also installed as a top-level alias ofctx.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), ormodule.exports = async (ctx) => …. Source ≤ 16 KB UTF-8 (inline) or 64 KB base64 (inlineB64); larger source must move to a content row. - Limits:
wallMsdefault 10 s (max 30 s),memoryMbdefault 256 (max 512), enforced by anisolated-vmmemory 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 409HOST_QUOTA_EXCEEDEDto the executor andHOST_QUOTA_EXCEEDEDto the function.
python-pyodide-v1 is deferred (ADR-0008); manifests pinning it fail validation with RUNTIME_DISABLED. wasm-v1 is reserved for v2.