Skip to content
Back to site

Backend (_server.js)

Mini-apps can have a backend by creating a _server.js file. The backend runs server-side in Hivekeep’s process and is accessible via a scoped API.

Create _server.js via the write_mini_app_file tool:

export default function(ctx) {
const app = new ctx.Hono();
app.get("/hello", (c) => {
return c.json({ message: "Hello from the backend!" });
});
return app;
}

The file must default-export a function that receives a context object and returns a Hono app (or any object with a .fetch() method).

Besides the default export, a backend can export lifecycle hooks. All exports are optional, but at least one of default / onStart / onStop is required:

export async function onStart(ctx) {
// Runs when the backend instance loads (boot or first request).
// Start jobs, warm caches, open watchers here.
}
export async function onStop(ctx) {
// Runs before the instance is unloaded or reloaded (5s budget).
// ctx.timers and ctx.schedule jobs are cleaned up automatically.
}
export function onClientEvent(ctx, event, data, meta) {
// Receives events sent from the UI via Hivekeep.events.send().
// meta = { userId, userName }. The return value is sent back to the caller.
if (event === "vote") return { accepted: true };
}

By default a backend is loaded lazily on its first HTTP request and stays passive between requests. Declare background mode in app.json to make it a live service:

{ "background": true }

A background backend:

  • loads at server boot (and onStart runs immediately),
  • is restarted automatically after every _server.js / app.json edit (stop → reload → onStart),
  • keeps its scheduled jobs and timers running with no UI open.

ctx.schedule(name, cronPattern, handler) registers a named cron job (standard cron syntax, evaluated with the server’s timezone):

export async function onStart(ctx) {
ctx.schedule("poll-feed", "*/15 * * * *", async () => {
const res = await ctx.fetch("https://api.example.com/feed");
const items = await res.json();
await ctx.storage.set("items", items);
ctx.events.emit("feed:updated", { count: items.length });
});
}
  • Max 10 jobs per app; runs are overlap-protected and spaced at least 15 seconds apart.
  • Re-registering an existing name replaces the job. ctx.schedule(...) returns { stop() }.
  • Jobs stop automatically when the instance stops or reloads.
  • Handler errors land in the app console (get_mini_app_console).

ctx.on(eventType, handler) subscribes to Hivekeep’s platform events, the same catalogue the app sends over SSE (see the SSE reference). This is what makes a background app reactive instead of having to poll: run something the moment a task finishes, a message arrives on a channel, a contact is created, a cron fires.

export async function onStart(ctx) {
ctx.on("task:done", (event) => {
// event = { type, agentId?, data }
ctx.notify("Task finished", String(event.data.taskId));
});
ctx.on("channel:message-received", (event) => {
ctx.storage.set("lastInbound", event.data);
ctx.events.emit("inbound", event.data); // forward to the open UI
});
}
  • Gated by the events:<prefix> permission: events:task covers task:*, events:channel covers channel:*, etc. Declare them in app.json.
  • The handler receives { type, agentId?, data }. Returns an unsubscribe function; all subscriptions are torn down automatically on reload/stop. Max 30 per app.
  • High-frequency / internal events are not subscribable (chat:token, queue:update, *-token-usage, …): a per-token handler would be a footgun.
  • Avoid feedback loops: a handler reacting to an event that itself triggers that event (e.g. handling contact:updated by updating a contact) will recurse. The action capabilities are rate-limited, but design handlers to be idempotent / guarded.

Never use the global setInterval/setTimeout in a backend: they would survive reloads and leak. Use the managed equivalents:

const id = ctx.timers.setInterval(() => { /* ... */ }, 60_000); // min 1s
ctx.timers.clearInterval(id);
// ctx.signal aborts when the instance stops — pass it to long work:
await fetch(url, { signal: ctx.signal });

All managed timers are cleared automatically when the instance stops.

ctx.notify(title, body?) sends a persistent platform notification (notification center, SSE, and the user’s configured external channels). Rate-limited to 10 per hour per app.

await ctx.notify("Price alert", "AAPL crossed $200");

Backends can access platform capabilities after the user approves them. Declare what you need in app.json:

{
"background": true,
"permissions": ["llm", "agent:inform", "secrets:OPENWEATHER_API_KEY"]
}

When the app panel is open and permissions are missing, Hivekeep shows an approval banner. Until granted, the matching ctx members throw a descriptive error.

CapabilityPermissionLimitDescription
ctx.secrets.get(name)secrets:<NAME> (per secret)noneRead a vault secret. Never store API keys in code or storage.
ctx.llm.complete(prompt, opts?)llm30/hourOne-shot LLM completion via the platform’s providers (defaults to the maintainer Agent’s model). opts: { model, providerId, maxTokens }.
ctx.agent.inform(text)agent:inform10/hourDrop an informational message into the maintainer Agent’s queue.
ctx.agent.task(description, opts?)agent:task5/hourSpawn an async sub-task on the maintainer Agent. Returns { taskId }.
ctx.channels.list() / .send(channelId, chatId, text) / .sendToContact(contact, platform, text)channels:send20/hourSend through the platform’s existing messaging channels (Telegram, Discord, Twilio SMS, plugin platforms…). sendToContact resolves the contact’s platform identifier and an active channel automatically. Prefer this over re-implementing a provider API with raw secrets: one permission, audited sends, no credentials in the app.
ctx.on(eventType, handler)events:<prefix> (e.g. events:task)30 subscriptionsSubscribe to platform events (Reacting to Platform Events).
ctx.platform.get/post/put/patch/delete(path)platform:<resource>:<read|write>noneManage platform resources from the backend (Managing Platform Resources).

ctx.permissions exposes { requested, granted, has(permission) } for introspection.

ctx.platform lets a backend read and mutate platform resources, the background counterpart of the frontend Hivekeep.platform. The frontend gateway re-dispatches to the full REST API with the user’s session; a background backend has no user session and runs unattended, so ctx.platform instead routes through an explicit, service-backed set of resources (the safer trust model for autonomous code).

// app.json: { "permissions": ["platform:tickets:write", "platform:contacts:read"] }
export function onClientEvent(ctx, event, data) {
// e.g. turn an inbound request into a ticket
return ctx.platform.post("/tickets", { projectId: data.projectId, title: data.title })
}
await ctx.platform.get("/contacts") // list
await ctx.platform.get("/contacts/" + id) // one
await ctx.platform.get("/tickets?projectId=" + p) // tickets need a projectId
await ctx.platform.patch("/contacts/" + id, { lastName: "King" })
await ctx.platform.delete("/crons/" + id)
  • Available resources: contacts, projects, tickets, crons (more can be added). Anything else throws “not available from a background mini-app”.
  • Method → operation: GET (no id) = list, GET /res/:id = get, POST = create, PUT/PATCH /res/:id = update, DELETE /res/:id = remove.
  • Gated by platform:<resource>:<read|write> (a write grant implies read), same permission ids as the frontend gateway.
  • Writes act with the maintainer Agent’s identity. Crons created this way are Agent-created and require user approval before they activate.

These are always available (no permission needed):

  • ctx.fetch(url, options?): SSRF-guarded fetch: http/https only, private/internal hosts blocked, 30s default timeout.
  • ctx.files: file storage scoped to the app’s _data/ directory (excluded from snapshots and rollbacks):
await ctx.files.write("cache/feed.json", JSON.stringify(items));
const raw = await ctx.files.read("cache/feed.json"); // string | null
await ctx.files.list(); // [{ path, size }]
await ctx.files.exists("cache/feed.json");
await ctx.files.delete("cache/feed.json");
PropertyTypeDescription
ctx.HonoclassHono constructor (no import needed)
ctx.storageobjectKey-value storage scoped to this app (see Storage)
ctx.eventsobjectSSE event emitter (see Real-Time Events)
ctx.appIdstringThe mini-app’s ID
ctx.agentIdstringThe maintainer Agent’s ID
ctx.appNamestringThe mini-app’s display name
ctx.versionnumberApp version this instance was loaded from
ctx.backgroundbooleanWhether app.json declares "background": true
ctx.signalAbortSignalAborted when the instance stops (see Timers)
ctx.timersobjectManaged timers, auto-cleared on stop (see Timers)
ctx.schedulefunctionNamed cron jobs (see Scheduled Jobs)
ctx.onfunctionSubscribe to platform events (see Reacting to Platform Events)
ctx.notifyfunctionPlatform notifications (see Notifications)
ctx.fetchfunctionSSRF-guarded outbound HTTP (see Outbound HTTP and Files)
ctx.filesobjectScoped _data/ file storage (see Outbound HTTP and Files)
ctx.secrets / ctx.llm / ctx.agent / ctx.channelsobjectPermission-gated capabilities (see Capability Permissions)
ctx.permissionsobject{ requested, granted, has() } introspection
ctx.logobjectScoped logger (see Logging)

Backend routes are served at:

/api/mini-apps/<appId>/api/*

Define routes using standard Hono patterns:

export default function(ctx) {
const app = new ctx.Hono();
app.get("/items", async (c) => {
const items = await ctx.storage.get("items") ?? [];
return c.json(items);
});
app.post("/items", async (c) => {
const body = await c.req.json();
const items = await ctx.storage.get("items") ?? [];
items.push({ id: Date.now(), ...body });
await ctx.storage.set("items", items);
return c.json({ success: true });
});
app.delete("/items/:id", async (c) => {
const id = Number(c.req.param("id"));
const items = await ctx.storage.get("items") ?? [];
await ctx.storage.set("items", items.filter(i => i.id !== id));
return c.json({ success: true });
});
return app;
}

From React, use the useApi hook:

import { useApi } from "@hivekeep/react";
function ItemList() {
const { data: items, loading, error, refetch } = useApi("/items");
const addItem = async (name) => {
await Hivekeep.api.post("/items", { name });
refetch();
};
// ...
}

The useApi hook accepts an optional second argument with method, body, headers, and enabled options. Pass null as the path to skip fetching.

Or use the raw API client directly:

// GET + parse JSON
const items = await Hivekeep.api.get("/items");
// POST JSON
await Hivekeep.api.post("/items", { name: "New item" });
// PUT, PATCH, DELETE
await Hivekeep.api.put("/items/123", { name: "Updated" });
await Hivekeep.api.patch("/items/123", { name: "Patched" });
await Hivekeep.api.delete("/items/123");
// Raw fetch (returns Response object)
const response = await Hivekeep.api("/items", { method: "GET" });

The backend can push events to the frontend in real-time using ctx.events.

export default function(ctx) {
const app = new ctx.Hono();
app.post("/process", async (c) => {
const body = await c.req.json();
// Emit progress events to all connected clients
ctx.events.emit("progress", { step: 1, total: 3 });
// ... do work ...
ctx.events.emit("progress", { step: 2, total: 3 });
// ... more work ...
ctx.events.emit("progress", { step: 3, total: 3 });
ctx.events.emit("done", { result: "Complete!" });
return c.json({ success: true });
});
// Check how many clients are listening
app.get("/listeners", (c) => {
return c.json({ count: ctx.events.subscriberCount });
});
return app;
}
import { useEventStream } from "@hivekeep/react";
function ProcessMonitor() {
const { messages, connected, clear } = useEventStream("progress");
// Or with a callback (no accumulation):
useEventStream("done", (data) => {
Hivekeep.toast(data.result, "success");
});
return (
<div>
{messages.map((msg, i) => (
<p key={i}>Step {msg.data.step}/{msg.data.total}</p>
))}
<button onClick={clear}>Clear messages</button>
</div>
);
}

Each message in messages has the shape { event, data, ts }.

// Listen for a specific event
Hivekeep.events.on("progress", (data) => {
console.log(`Step ${data.step}/${data.total}`);
});
// Listen for all events
Hivekeep.events.subscribe(({ event, data }) => {
console.log(event, data);
});
// Check connection status
console.log(Hivekeep.events.connected);
// Disconnect
Hivekeep.events.close();

ctx.events.emit(event, data, { userId }) delivers only to that user’s connections (useful for per-user state in shared apps):

ctx.events.emit("your-turn", { board }, { userId: meta.userId });

The upstream half of the realtime channel: the UI sends an event, the backend’s onClientEvent export handles it and can answer.

// Frontend
const ack = await Hivekeep.events.send("vote", { choice: "A" });
// ack = { handled: true, result: { accepted: true } }
// or from React:
const { send } = useEventStream();
await send("vote", { choice: "A" });
// Backend (_server.js)
export function onClientEvent(ctx, event, data, meta) {
if (event === "vote") {
ctx.events.emit("votes-changed", { by: meta.userName });
return { accepted: true };
}
}

The backend shares the same storage namespace as the frontend. Data written by one is readable by the other.

MethodReturnsDescription
ctx.storage.get(key)Promise<unknown | null>Get a value (auto JSON-parsed)
ctx.storage.set(key, value)Promise<void>Set a value (auto JSON-serialized)
ctx.storage.delete(key)Promise<boolean>Delete a key
ctx.storage.list()Promise<{ key, size }[]>List all keys with sizes
ctx.storage.clear()Promise<number>Delete all keys, returns count
// Backend
await ctx.storage.set("config", { theme: "dark" });
const keys = await ctx.storage.list();
// [{ key: "config", size: 22 }]
// Frontend
const [config] = useStorage("config");
// config === { theme: "dark" }

Backends are cached by version number. When you update _server.js via write_mini_app_file, the running instance is stopped cleanly (onStop, jobs and timers cleared) and reloaded: immediately for background apps, on the next request otherwise. No manual restart needed.

Use the get_mini_app_backend_status tool to inspect the live instance: loaded state, background mode, scheduled jobs with next run times, active timers, SSE subscribers, and the permission state.

ctx.log.info("Processing request");
ctx.log.warn("Something looks off");
ctx.log.error("Something went wrong:", err.message);
ctx.log.debug("Received data:", data);

Logs appear in Hivekeep’s server logs tagged with the app ID, and info/warn/error entries also land in the app console (readable with get_mini_app_console, marked source: backend). The logger accepts simple string arguments (not structured objects like pino).