Skip to content

Backend API Reference

The backend runs in the host Bun process. It’s optional — only needed when your instrument requires server-side logic.

Creates a backend definition. Must be the default export of your backend entry file.

import { defineBackend } from "tango-api/backend";
export default defineBackend({
kind: "tango.instrument.backend.v2",
actions: { /* ... */ },
onStart: async (ctx) => { /* ... */ },
onStop: async () => { /* ... */ },
});
type InstrumentBackendDefinition = {
kind: "tango.instrument.backend.v2";
actions: Record<string, InstrumentBackendAction<any, any>>;
onStart?: (ctx: InstrumentBackendContext) => Promise<void> | void;
onStop?: () => Promise<void> | void;
onBackgroundRefresh?: (ctx: InstrumentBackgroundRefreshContext) => Promise<void> | void;
};

Must be "tango.instrument.backend.v2". Required for version negotiation.

A map of action name → action definition. Each action is callable from the frontend via api.actions.call(name, input) or useInstrumentAction(name).

Called when the instrument backend loads. Use it for initialization (creating tables, setting up state, etc.).

Called when the instrument unloads or is suspended (user navigated away). Use it for cleanup.

Called periodically while the instrument is suspended, if backgroundRefresh is enabled in the manifest. Receives a restricted context — only storage, settings, emit, and logger are available. See Background Refresh for full details.


type InstrumentBackendAction<I = unknown, O = unknown> = {
input?: ActionSchema;
output?: ActionSchema;
handler: (ctx: InstrumentBackendContext, input: I) => Promise<O> | O;
};

Optional JSON schema describing the expected input. Currently used for documentation; future versions will validate inputs against this schema.

Optional JSON schema describing the return value.

The function that runs when the action is called. Receives the backend context and the caller’s input. Can be sync or async.


type InstrumentBackendContext = {
instrumentId: string;
permissions: InstrumentPermission[];
emit: (event: Omit<InstrumentEvent, "instrumentId">) => void;
host: InstrumentBackendHostAPI;
task: {
(label: string): TaskHandle;
<T>(label: string, fn: () => Promise<T> | T): Promise<T>;
};
};
type TaskHandle = {
done(): void;
fail(error?: string): void;
};

The instrument’s unique ID from the manifest.

The permissions granted to this instrument.

Sends a custom event to the frontend. The instrumentId is added automatically.

ctx.emit({
event: "processing.done",
payload: { resultCount: 42 },
});

Access to the same host APIs as the frontend:

type InstrumentBackendHostAPI = {
storage: StorageAPI;
sessions: SessionsAPI;
connectors: ConnectorsAPI;
stages: StageAPI;
events: HostEventsAPI;
settings: InstrumentSettingsAPI;
};

All methods work identically to the frontend versions. See Frontend API Reference for details on each.

Registers a long-running task that prevents backend suspension while it runs. When the user navigates away, suspension is deferred until all active tasks complete.

Handle-based — for fire-and-forget or streaming flows:

const handle = ctx.task("Reviewing PR #42");
try {
const review = await runAgentReview(prUrl);
await ctx.host.storage.setProperty("review", review);
ctx.emit({ event: "review.done", payload: { review } });
handle.done();
} catch (err) {
handle.fail(err.message);
}

Callback-based (recommended) — auto-completes on success, auto-fails on error:

const result = await ctx.task("Reviewing PR #42", async () => {
const review = await runAgentReview(prUrl);
await ctx.host.storage.setProperty("review", review);
ctx.emit({ event: "review.done", payload: { review } });
return review;
});

Behavior:

  • Multiple tasks can run concurrently. Suspension is deferred until all complete.
  • Tasks have a 5-minute hard timeout. Exceeding it auto-completes with a warning log.
  • done() and fail() are idempotent — safe to call multiple times.
  • If the user navigates back while tasks run, the deferred suspension is cancelled.
  • When an instrument is disabled or removed, all tasks are force-completed.

When to use: Agent reviews, content generation, session queries — any operation taking more than a few seconds that should survive navigation.

When NOT to use: For backends that need to stay alive permanently, use backgroundRefresh: { mode: "keep-alive" } instead.


A restricted version of the backend context passed to onBackgroundRefresh. Limits available APIs to prevent heavy workloads during background refresh.

type InstrumentBackgroundRefreshContext = {
instrumentId: string;
permissions: InstrumentPermission[];
emit: (event: Omit<InstrumentEvent, "instrumentId">) => void;
logger: LoggerAPI;
host: {
storage: StorageAPI;
settings: InstrumentSettingsAPI;
};
};
APIAvailableNotes
storageYesFull read/write access
settingsYesRead/write instrument settings
emitYesSend events
loggerYesLog messages at any level
sessionsNoBackground refresh should not spawn Claude sessions
connectorsNoToo heavy for periodic background work
stagesNoNot needed for data refresh
events.subscribeNoSubscriptions are cleared on suspend

See Background Refresh for lifecycle flow, concurrency rules, and common patterns.


Used for input and output type declarations on actions:

type ActionSchema =
| { type: "any" }
| { type: "null" }
| { type: "string" }
| { type: "number" }
| { type: "boolean" }
| { type: "array"; items?: ActionSchema }
| {
type: "object";
properties?: Record<string, ActionSchema>;
required?: string[];
additionalProperties?: boolean;
};

Simple string input:

input: { type: "string" }

Object with required fields:

input: {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
required: ["name"],
}

Array of objects:

output: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
title: { type: "string" },
},
},
}

import { defineBackend, type InstrumentBackendContext } from "tango-api/backend";
type CreateTaskInput = {
stagePath: string;
title: string;
notes?: string;
};
type TaskSummary = {
id: string;
title: string;
status: string;
};
export default defineBackend({
kind: "tango.instrument.backend.v2",
onStart: async (ctx) => {
await ctx.host.storage.sqlExecute(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
stage_path TEXT NOT NULL,
title TEXT NOT NULL,
notes TEXT DEFAULT '',
status TEXT DEFAULT 'todo'
)
`);
},
actions: {
createTask: {
input: {
type: "object",
properties: {
stagePath: { type: "string" },
title: { type: "string" },
notes: { type: "string" },
},
required: ["stagePath", "title"],
},
handler: async (ctx, input) => {
const { stagePath, title, notes } = input as CreateTaskInput;
const id = crypto.randomUUID();
await ctx.host.storage.sqlExecute(
"INSERT INTO tasks (id, stage_path, title, notes) VALUES (?, ?, ?, ?)",
[id, stagePath, title, notes ?? ""]
);
ctx.emit({ event: "tasks.changed", payload: { stagePath, taskId: id } });
return { id };
},
},
listStageTasks: {
input: {
type: "object",
properties: { stagePath: { type: "string" } },
required: ["stagePath"],
},
output: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
title: { type: "string" },
status: { type: "string" },
},
},
},
handler: async (ctx, input) => {
const { stagePath } = input as { stagePath: string };
return ctx.host.storage.sqlQuery<TaskSummary>(
"SELECT id, title, status FROM tasks WHERE stage_path = ?",
[stagePath]
);
},
},
},
});