Skip to content

Writing serverless functions

A serverless function is your own code running on the accelerator. Reach for one when something can’t live in the client: a secret you can’t ship to the browser, a call to a third-party API, server-side validation, or logic an agent calls as a tool. This page is the authoring playbook — the client.functions reference has the full management API.

A function is a single ES module that default-exports a fetch handler — the same shape as the request you’d write for any edge runtime:

export default {
async fetch(request) {
const { name } = await request.json().catch(() => ({}));
return Response.json({ greeting: `Hello, ${name ?? "world"}!` });
},
};

It owns its own routing and CORS — branch on new URL(request.url).pathname and request.method to expose multiple routes from one function. Answer OPTIONS and set Access-Control-Allow-Origin / -Headers (include Content-Type, X-Muhkoo-Session and X-Muhkoo-Key) on every response, or a browser can’t call it.

The handler’s second argument, env, carries the app’s id and environment, the platform API handle (MUHKOO_API), a scoped credential (MUHKOO_APP_KEY) and the model gateway (MUHKOO_AI) — see The function environment. The source is one module of up to 1 MB with no build step, so don’t import npm packages.

Deploy with the SDK or the CLI. The name is a DNS-safe slug — it becomes the function’s subdomain label.

const { config } = await client.functions.deploy(appId, {
name: "greet",
displayName: "Greeter",
code: greetSource, // the module above, as a string
triggers: { http: { enabled: true } },
});
// Reachable at https://greet--<app-slug>.fns.<domain>/

client.functions.invoke calls an HTTP-triggered function and parses its JSON. The SDK attaches the caller’s credentials automatically — the app key, and the user’s session when one is signed in — so you don’t assemble the URL or wire up auth headers yourself:

const out = await client.functions.invoke<{ greeting: string }>(
{ name: "greet", slug: "myapp" },
{ body: { name: "Ada" } }, // a body implies POST
);

The platform passes the session token through to the function as X-Muhkoo-Session, unverified. To act as the user, verify it server-side and trust nothing the client claims. Reach the platform through the MUHKOO_API binding, not a bare fetch to its public URL (a function calling the platform’s own hostname is refused):

const api = (path, init) => env.MUHKOO_API.fetch(env.MUHKOO_API_URL + path, init);
const token = request.headers.get("X-Muhkoo-Session");
const verify = await api("/api/auth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
if (!verify.ok) return new Response("Sign in required", { status: 401 });
const { username, commitment } = await verify.json(); // the verified caller

This is the core “authenticated proxy” pattern: the browser calls the function with its session, the function verifies it and then uses a secret (an admin key, a third-party token) that never reaches the browser. Bake secrets in at deploy time rather than shipping them client-side.

Inference is available inside a function through the env.MUHKOO_AI binding. It’s the whole surface: one POST /ai/run, taking the model id and the messages, and returning what the model said. There is no direct model binding — a function that reached a model on its own would be spending money the platform never saw, so the only path is one that meters and attributes the call.

The credential it needs is already there: the platform injects a scoped access token as env.MUHKOO_APP_KEY, and it carries the ai:infer scope. A complete, deployable function:

export default {
async fetch(request, env) {
const { question } = await request.json().catch(() => ({}));
if (!question) {
return Response.json({ error: "question is required" }, { status: 400 });
}
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.1-8b-instruct",
messages: [
{ role: "system", content: "Answer in one short paragraph." },
{ role: "user", content: question },
],
max_tokens: 512,
}),
});
if (!res.ok) {
// 402 = over quota, 400 = unknown model or bad body, 502 = the model failed.
return Response.json({ error: await res.text() }, { status: res.status });
}
const result = await res.json();
const answer = result.response ?? result.choices?.[0]?.message?.content ?? "";
return Response.json({ answer });
},
};

The hostname in that URL is ignored — env.MUHKOO_AI is a direct handle to the gateway, not a name to resolve — but the path and method are fixed. model is a catalog id in its prefix-less form (meta/llama-3.1-8b-instruct, openai/gpt-oss-120b); only ids in the published catalog are accepted, and an unknown one fails with 400 before any tokens are spent. GET /api/apps/agent-models lists them all, with per-model pricing and the tool-capable subset.

A reply that appears all at once after four seconds reads as a hang; the same four seconds with text arriving does not. Pass stream: true and hand the body straight to your caller — no buffering, no re-encoding:

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 may buffer the whole body,
// which undoes the streaming entirely.
"X-Accel-Buffering": "no",
},
});

A function can also run on messages in a Space — the same model as an agent, but your code is the brain. It receives the decrypted message; a non-empty response body is posted back as a reply.

await client.functions.update(appId, fnId, {
triggers: { http: { enabled: true }, space: { match: [{ type: "keyword", pattern: "/weather" }] } },
});
await client.functions.enable(appId, fnId, channelSpaceId); // per-Space opt-in
// In the function, handle the Space event (POST /_muhkoo/event):
if (request.headers.get("X-Muhkoo-Event") === "space.message") {
const { text, sender, spaceId } = await request.json();
return Response.json({ reply: `You said: ${text}` }); // posted to the Space
}

An empty body or a 204 stays silent. Keep the event branch ahead of your HTTP routes, since a function bound to both triggers receives both kinds of request.

Every function exists independently in test and production — the same way hosting and config do. Which one you touch is set by the environment of the key you deploy with (mk_test_sk_* → test) or the portal’s env selector. Iterate in test, then promote test → production in place once you’re happy. See Environments.

  1. Deploy to test and exercise it at <name>--<slug>.test.fns.<domain>.
  2. When it’s right, promote — production functions are upserted by name.
  3. Production traffic is never touched until you promote.
  • client.functions reference — every method, the full FunctionConfig, the Space-event contract, and caps/billing.
  • Building agents — let an AI agent call your functions as tools, or reach for one when the model belongs in a Space rather than in a request.
  • Environments & deploy — the test → production model.