client.functions
A serverless function is developer-authored code that runs on the
accelerator. You write a single ES module that default-exports a fetch handler
(export default { fetch }), deploy it, and invoke it one of two ways: over
HTTP at its own subdomain, or bound to a Space so it
runs on messages (the same model as a Programmable
Agent, but your code is the “brain” instead of an LLM). Your
client only manages functions — they execute on the accelerator.
How it runs
Section titled “How it runs”- Compute-only (v1). A function runs in an untrusted, isolated sandbox with
no privileged platform bindings — no database, storage, Space or model
handle it could use without presenting a credential. It talks to the platform like any
external client (through the
MUHKOO_APIbinding) and acts on behalf of the invoking user (theirX-Muhkoo-Sessionheader is passed through, unverified). It cannot reach another app’s data. - One module, no bundling. The source is a single ES module of up to 1 MB,
uploaded as written. There is no server-side build step, so
importof an npm package won’t resolve: inline what you need, or call the platform overfetch. Node built-ins are available (the runtime enables Node compatibility). - Encrypted at rest, JIT-decrypted. Your source is stored encrypted and decrypted only at invocation time, when it’s uploaded to the runtime. An idle function is torn down automatically and re-uploaded on the next call — so the first request after a cold period is a little slower (a one-time upload), then warm calls are fast. A warm function is torn down after about 10 minutes without an invocation.
The function environment
Section titled “The function environment”Every function receives these on env:
| Binding | Kind | What it is |
|---|---|---|
MUHKOO_APP_ID | string | The app’s id. |
MUHKOO_APP_ENV | string | live or test — which environment this script was deployed for. |
MUHKOO_FUNCTION_ID | string | This function’s id. |
MUHKOO_API_URL | string | Base URL of the platform API. |
MUHKOO_API | service binding | The only way to call the platform API from a function. |
MUHKOO_ACCESS_TOKEN | string | A scoped access token the platform mints for this function. |
MUHKOO_APP_KEY | string | The credential to send as X-Muhkoo-Key. Equal to the access token. |
MUHKOO_AI | service binding | Metered model inference — see Calling a model. |
MUHKOO_ACCESS_TOKEN / MUHKOO_APP_KEY are short-lived (about 7 days) and
rotated automatically when the function is next uploaded, so read them from env
on every request rather than caching them. The token carries db:read,
db:write, kv:read, kv:write, storage:read, storage:write,
functions:invoke and ai:infer; a function that is bound to a Space also gets
messages:read and messages:write. Because it is not a publishable key, it can
reach backend-only tables.
Call the platform through the MUHKOO_API binding, never with a bare fetch to
the API’s public URL — requests from a function to the platform’s own hostname
are refused (a 522), because both run on the same infrastructure. The hostname in
the URL is only used to build the path:
const api = (path, init) => env.MUHKOO_API.fetch(env.MUHKOO_API_URL + path, init);
const res = await api("/api/db/tasks/query", { method: "POST", headers: { "Content-Type": "application/json", "X-Muhkoo-Key": env.MUHKOO_APP_KEY }, body: JSON.stringify({ where: [{ column: "done", op: "eq", value: false }], limit: 20 }),});const { rows } = await res.json();The binding grants exactly what an external HTTPS client has: key or session
authentication, no tenant bypass. The @muhkoo/connect Client is not available
inside a function (the Workers build of the SDK omits it); use the REST routes
directly, as above.
Triggers
Section titled “Triggers”A function declares either or both triggers (toggle them any time from the editor):
http— the function is reachable athttps://<name>--<app-slug>.fns.<domain>/and receives the full request. It owns its own routes and CORS.space— the function is invoked on messages in Spaces you enable it on. It receives the decrypted message as input and its HTTP response body is posted back to the Space as a signed reply (an empty body /204stays silent). Matching uses the samekeyword/regex/alwaysrules as agents.
Methods
Section titled “Methods”// List the app's functions.client.functions.list(appId, opts?): Promise<FunctionConfig[]>
// Read one function's config.client.functions.get(appId, functionId, opts?): Promise<{ config: FunctionConfig }>
// Read a function's decrypted source (for an editor UI).client.functions.code(appId, functionId, opts?): Promise<{ functionId: string; code: string; codeHash: string | null }>
// Deploy (create) a function (owner/editor; paid tier).client.functions.deploy(appId, input, opts?): Promise<{ config: FunctionConfig }>
// Update editor-settable fields and/or redeploy the `code`.client.functions.update(appId, functionId, patch, opts?): Promise<{ config: FunctionConfig }>
// Delete a function (also tears down its deployed script + identity).client.functions.delete(appId, functionId, opts?): Promise<{ deleted: boolean }>
// Enable / disable the function on a specific Space (per-Space opt-in).client.functions.enable(appId, functionId, spaceId, opts?): Promise<{ config: FunctionConfig }>client.functions.disable(appId, functionId, spaceId, opts?): Promise<{ config: FunctionConfig }>
// Call an HTTP-triggered function. The SDK attaches the app key and, when a user// is signed in, their session token — so the function can verify the caller.client.functions.invoke<T>(target, opts?): Promise<T> // parses JSON; throws HttpError on non-2xxclient.functions.invokeRaw(target, opts?): Promise<Response> // for non-JSON responsesopts (management calls) is { space?: string } — a non-owner editor passes the
app’s management Space id for delegated access; the owner can omit it.
For invoke/invokeRaw, target is { name, slug }, the resolved host label
('hello--myapp'), or a full URL; opts is { path?, body?, method?, headers? }
(a body implies POST). See Writing serverless
functions for the calling and authenticated-proxy patterns.
Example
Section titled “Example”// 1. Deploy an HTTP function (only an app owner / editor on a paid plan can).const { config } = await client.functions.deploy(appId, { name: "hello", // DNS-safe slug — the function's subdomain label displayName: "Hello", code: `export default { async fetch(request) { return new Response("Hello from your Muhkoo function!"); }, };`, triggers: { http: { enabled: true } },});
// 2. Call it — reachable at its own subdomain.// https://hello--<app-slug>.fns.<domain>/
// 3. (Optional) also run it on a Space's messages.await client.functions.update(appId, config.functionId, { triggers: { http: { enabled: true }, space: { match: [{ type: "always" }] } },});await client.functions.enable(appId, config.functionId, channelSpaceId);invokeUrl(target, path?)
Section titled “invokeUrl(target, path?)”client.functions.invokeUrl("my-fn"); // https://my-fn.<functions host>client.functions.invokeUrl("my-fn", "/health"); // …/healthThe URL a function is reachable at, without calling it. Useful for a webhook you have to hand to a third party, or a link you want to render. Synchronous — it builds a string and makes no request.
Override the host with the functionsHostSuffix client option.
Configuration
Section titled “Configuration”FunctionConfig
Section titled “FunctionConfig”interface FunctionConfig { functionId: string; name: string; // DNS-safe slug; the subdomain label + script name displayName: string; triggers: FunctionTriggers; enabledSpaces: string[]; // Spaces the `space` trigger is active on caps: FunctionCaps; scriptName: string; codeHash?: string; // sha-256 of the last-deployed source deployedAt?: number; createdAt: number; updatedAt: number;}
interface FunctionTriggers { http?: { enabled: boolean; methods?: string[] }; // own-subdomain HTTP space?: { match: FunctionTrigger[] }; // invoked on Space messages}
interface FunctionTrigger { type: "keyword" | "regex" | "always"; pattern?: string; // required for "keyword" / "regex"}
interface FunctionCaps { cpuMs: number; // per-invocation CPU ceiling (clamped by plan) subRequests: number; // per-invocation subrequest ceiling (clamped by plan) dailyInvocationBudget: number; // hard daily circuit-breaker (0 = unlimited)}FunctionDeployInput is { name, displayName, code, triggers?, caps? };
FunctionUpdateInput is a partial of it (include code to redeploy the source).
A function with no triggers defaults to HTTP-only; an explicit triggers
object must declare at least one of http / space.
Validation: name is a DNS-safe slug (1-63 characters, lowercase letters, digits
and internal hyphens), displayName is 1-64 characters, code is non-empty and
at most 1,000,000 bytes, and http.methods (an optional allowlist, at most 8 of
GET POST PUT PATCH DELETE HEAD OPTIONS) is uppercased for you. A space trigger
list holds at most 32 entries. caps defaults to cpuMs: 50, subRequests: 50,
dailyInvocationBudget: 100000.
The Space-event contract
Section titled “The Space-event contract”When a Space-bound function fires, the accelerator dispatches a request to it:
POST /_muhkoo/eventX-Muhkoo-Event: space.message{ "type": "space.message", "spaceId": "…", "sender": "…", "text": "…", "functionId": "…" }A non-empty response body becomes the reply posted to the Space (a JSON
{ "reply": "…" } envelope is also honored); an empty body or 204 stays
silent.
Calling a model
Section titled “Calling a model”A function reaches model inference through one binding, env.MUHKOO_AI. It
speaks a single route, and everything that passes through it is metered against
your app:
const res = await env.MUHKOO_AI.fetch("https://fn-gateway/ai/run", { method: "POST", headers: { "Content-Type": "application/json", "X-Muhkoo-Key": env.MUHKOO_APP_KEY, }, body: JSON.stringify({ model: "meta/llama-3.3-70b-instruct-fp8-fast", messages: [{ role: "user", content: "Summarize this changelog." }], }),});const result = await res.json();The hostname is ignored: env.MUHKOO_AI is a direct handle to the gateway, not a
name to resolve, so anything parseable works there. The path must be /ai/run
and the method POST. X-Muhkoo-Key is required: the gateway holds no authority
of its own to lend, so it forwards the credential you present, and that
credential is what attributes the spend. The platform injects one for you as
env.MUHKOO_APP_KEY (a scoped access token), and it
carries the ai:infer scope.
Request fields
Section titled “Request fields”| Field | Type | Notes |
|---|---|---|
model | string | Required. A catalog id — see Models. |
messages | { role, content }[] | Required, non-empty; every entry needs a role. |
tools | array | Tool definitions, for a function-calling model. |
stream | boolean | true returns Server-Sent Events instead of JSON. |
max_tokens | number | Output ceiling, clamped to 1–16,384. |
temperature | number | Sampling temperature. |
Any other property is dropped rather than forwarded, so a field the runtime would
accept but the platform doesn’t price can’t ride along. The whole request body is
capped at 1 MB — a long transcript fits, an unbounded one gets a 413.
Streaming
Section titled “Streaming”Without stream, the reply is 200 and the model’s result as JSON. Where the
text sits depends on the model family: response for the Llama-style models,
choices[0].message.content for the OpenAI-compatible ones.
With stream: true the reply is a text/event-stream, and the body can be handed
straight back to your own caller:
const upstream = await env.MUHKOO_AI.fetch("https://fn-gateway/ai/run", { method: "POST", headers: { "Content-Type": "application/json", "X-Muhkoo-Key": env.MUHKOO_APP_KEY }, body: JSON.stringify({ model, messages, stream: true }),});return new Response(upstream.body, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", // Without this an intermediary is free to buffer the whole body, // which undoes the point of streaming at all. "X-Accel-Buffering": "no", },});Don’t buffer the stream to count what it produced — the platform meters it as it passes.
Models
Section titled “Models”model is a catalog id in its prefix-less form:
meta/llama-3.1-8b-instruct, meta/llama-3.3-70b-instruct-fp8-fast,
openai/gpt-oss-120b, qwen/qwen3-30b-a3b-fp8. Only ids in the published
catalog are accepted; anything else is rejected with 400 Unknown model: <id>
before a token is spent. That’s an allowlist, not a price lookup — a model nobody
has priced can’t quietly become a bill.
GET /api/apps/agent-models returns the whole catalog with per-model pricing,
the default model, and fnCallingModels — the subset that supports tool-use, and
therefore the only ids worth passing tools with.
Metering and errors
Section titled “Metering and errors”Every call is metered on the ai_inference axis in neurons,
computed from the chosen model’s input and output rates, and counted against the
app’s quota — streaming included. A caller that disconnects mid-stream is still billed for what
was generated: the tokens were produced whether or not anyone stayed to read them.
| Status | Meaning |
|---|---|
400 | Missing model or messages, a message without a role, malformed JSON, or Unknown model: <id>. |
401 | No X-Muhkoo-Key, or a key the platform can’t resolve. |
402 | The app is over its request quota. The body carries tier, current, limit and upgradeUrl. |
403 | The credential lacks ai:infer, is a publishable key (inference needs a secret key or access token), the account is suspended, or the call didn’t arrive through the gateway. |
413 | Request body over 1 MB. |
502 | The model call itself failed; detail says why. |
Cost & billing
Section titled “Cost & billing”Each invocation is metered to your account as a request, and each function
has a hard caps.dailyInvocationBudget circuit breaker (past it, the function
answers 429). Per-invocation cpuMs / subRequests are clamped to your plan’s
ceiling when the function is dispatched:
| Plan | cpuMs ceiling | subRequests ceiling |
|---|---|---|
| Pay as you go | 1,000 | 50 |
| Small business | 5,000 | 200 |
| Enterprise | 30,000 | 1,000 |
Calling a function whose owner is on the Free tier answers 402
(functions are paid), as does an app over its request quota. If the owner’s
payment is past due the call answers 402 with the code owner_payment_past_due.
Model calls are billed on top, on the ai_inference axis — see Calling a
model. An inference call meters as a request too, and the
request quota is checked before the model runs, so an app at its cap gets a
402 rather than a surprise bill.