host.fn
host.fn invokes a custom function that runs on the server, not in the iframe. Use it for anything the browser should not do directly: hitting a third-party API with a secret key, heavy compute, writing to a shared store, or returning data that must be authoritative.
interface FnNamespace {
invoke<R = unknown>(ref: string, args?: Record<string, unknown>): Promise<R>;
}The call is server-mediated. It runs as the current viewer (so it sees their auth), but billing and quotas land on the app-instance owner. That makes host.fn the right place for a shared, owner-funded capability every viewer of a shared app can use.
The ref argument
| Form | Targets |
|---|---|
'forecast' (bare name) | An endpoint on your own bundle's agent. |
'robutler/<endpoint>' | The system robutler agent. |
'<agent>/<endpoint>' | A granted agent only. Arbitrary agents are rejected. |
await host.ready();
const data = await host.fn.invoke('forecast', { city: 'Lisbon' });
render(data);In detached mode (host.detached === true) there is no host bridge, so invoke rejects with unknown_op.
Declaring a function
Functions are declared in the bundle's widget.json tools[], each pointing at a source file in the bundle:
{
"name": "Weather",
"tools": [
{
"name": "forecast",
"description": "Return a forecast for a city.",
"_meta": {
"robutler": {
"file": "tools/forecast.js",
"runtime": "node",
"expose": ["http"],
"path": "/forecast",
"auth": "public"
}
}
}
]
}// tools/forecast.js: runs server-side; secrets stay here, never in the iframe.
export default async function forecast(ctx) {
const { city } = (ctx.request && ctx.request.body) || {};
const r = await fetch(`https://api.example.com/forecast?city=${encodeURIComponent(city)}`, {
headers: { authorization: `Bearer ${process.env.WEATHER_KEY}` },
});
return { status: 200, headers: { 'content-type': 'application/json' }, body: await r.text() };
}widget_publish wires each declared tool onto the bundle's dedicated agent as a custom function.
Auth modes
The auth field controls who may call the function:
| Mode | Who can call |
|---|---|
public | Anyone, including signed-out /w/<id> visitors. Use for read-only demo data. |
session | The authenticated viewer (the default for canvas apps). |
visitor_session | Per-instance visitor scope (owner-only analytics roll-ups). |
portal_token | Another agent over inter-agent RPC. |
signature | An HMAC-signed caller. |
public functions are what make a shared deck or board render for signed-out visitors; session and visitor_session only resolve for apps mounted on a real canvas.
Persistent state: ctx.kv scopes
Functions get a server-side key-value store, ctx.kv. By default it is private to the function (get/put/delete/list). ctx.kv.at(scope) opens a scoped view so cooperating functions share exactly the right partition:
export default async function submitScore(ctx) {
const board = ctx.kv.at('app'); // one board per widget type
const b = (await board.get('board')) || { entries: [] };
// ...validate server-side, insert, cap...
await board.put('board', b);
}| Scope | Shared across | Typical use |
|---|---|---|
fn (default) | nothing — this function only | private state |
agent | every function on the agent | multi-function webapps |
app | every instance of the widget type | leaderboards, marketplaces, counters |
instance | one mounted widget item | per-deployment state |
instanceOwner | one item, stored in the OWNER's store | per-instance private roll-ups |
project | the widget's project folder | shared project state |
projectOwner | the project, stored in the OWNER's store | owner-only analytics (what powers the slides dashboard) |
Rules that keep this safe:
- Declare the scope in the function's
permissions.kvblock (app,instance,instance_owner,project,project_owner, each"ro"or"rw"). Undeclared scopes are denied. - Widget scopes only resolve through a widget mount. The platform stamps the partition ids (which app, which item, which project) server-side from the mounted instance — a direct agent call has no widget context and is denied. Request bodies can never choose the partition.
- Scopes are cross-function on purpose: a write fn and a read fn (e.g.
submit-score+leaderboard-top) address one store; each consents by declaring the permission. - The store principal pays: app/instance/project writes count against the app's agent; the
*Ownerscopes live in the human owner's store (which is also why owner-gated analytics PII never sits in a shared store). Daily per-store quotas apply on top of per-invocation ones.
Free leaderboard, zero config
The system agent ships a generic pair every widget can use as-is — the board is automatically partitioned per widget type:
await host.fn.invoke('robutler/submit-score', { name: 'ada', score: 4242 }); // → { ok, rank, total }
const { entries } = await host.fn.invoke('robutler/leaderboard-top', { limit: 10 }); // PII-freeThe 64KB size cap
Custom functions in the js-v1 runtime have a hard size limit. A function source larger than 64KB is rejected at publish time with CODE_TOO_LARGE. The function is then stored as a stub, so calling it later returns 404 FN_NOT_FOUND. If you embed data (a lookup table, a template, a model) inline in the source, minify it or move it out of the function body, or you will silently ship a broken endpoint. Keep function source lean and load large assets at runtime instead.
Errors
unknown_op: the function name is not declared, or you are in detached mode (checkhost.detached).CODE_TOO_LARGE/FN_NOT_FOUND: the 64KB cap pitfall above.- Auth and quota failures surface per the error codes.
Keep heavy data out of the response when you can. host.fn round-trips through the host bridge, so it is for authoritative results, not bulk streaming (use host.live for that).
Related
- host.discover: find an agent, then call one of its endpoints
- host.agents: drive a granted agent's conversation
- host.content: store the bytes a function returns
- widget.json manifest: declaring
tools[]