Skip to content

Tutorial 4: Backend Actions

Backend actions run in the host Bun process — outside the browser sandbox. Use them for file system access, network requests, heavy computation, or anything that doesn’t belong in the frontend.

Update your manifest in package.json:

{
"tango": {
"instrument": {
"backendEntrypoint": "./dist/backend.js"
}
}
}

Create src/backend.ts:

import { defineBackend, type InstrumentBackendContext } from "tango-api/backend";
export default defineBackend({
kind: "tango.instrument.backend.v2",
actions: {
greet: {
input: {
type: "object",
properties: {
name: { type: "string" },
},
},
output: {
type: "object",
properties: {
greeting: { type: "string" },
timestamp: { type: "number" },
},
required: ["greeting", "timestamp"],
},
handler: async (ctx, input) => {
const name = (input as { name?: string })?.name ?? "world";
return {
greeting: `Hello, ${name}!`,
timestamp: Date.now(),
};
},
},
},
});

Each action has three parts:

PartRequiredDescription
inputNoJSON schema describing the expected input
outputNoJSON schema describing the return value
handlerYesAsync function (ctx, input) => output

Every handler receives an InstrumentBackendContext as its first argument:

type InstrumentBackendContext = {
instrumentId: string;
permissions: InstrumentPermission[];
emit: (event: { event: string; payload?: unknown }) => void;
host: {
storage: StorageAPI;
sessions: SessionsAPI;
connectors: ConnectorsAPI;
stages: StageAPI;
events: HostEventsAPI;
settings: InstrumentSettingsAPI;
};
};

You have full access to the same APIs as the frontend, plus the ability to emit events back to the UI.

Use useInstrumentAction to create a typed caller:

import { useInstrumentAction, useInstrumentApi, UIRoot, UIPanelHeader, UISection, UICard, UIButton, UIInput } from "tango-api";
import { useState } from "react";
type GreetInput = { name: string };
type GreetOutput = { greeting: string; timestamp: number };
function SidebarPanel() {
const api = useInstrumentApi();
const greet = useInstrumentAction<GreetInput, GreetOutput>("greet");
const [name, setName] = useState("");
const [result, setResult] = useState<GreetOutput | null>(null);
const handleGreet = async () => {
const output = await greet({ name });
setResult(output);
};
return (
<UIRoot>
<UIPanelHeader title="Greeter" />
<UISection>
<UICard>
<UIInput value={name} placeholder="Your name" onInput={setName} />
<UIButton label="Greet" variant="primary" onClick={handleGreet} />
{result && <p>{result.greeting} (at {new Date(result.timestamp).toLocaleTimeString()})</p>}
</UICard>
</UISection>
</UIRoot>
);
}
const greet = useInstrumentAction<TInput, TOutput>("greet");
// Returns: (input?: TInput) => Promise<TOutput>

It’s a thin wrapper around api.actions.call("greet", input) that is stable across re-renders (uses useCallback internally).

The backend supports onStart and onStop:

export default defineBackend({
kind: "tango.instrument.backend.v2",
actions: { /* ... */ },
onStart: async (ctx) => {
// Runs when the instrument loads
console.log(`Backend started for ${ctx.instrumentId}`);
},
onStop: async () => {
// Runs when the instrument unloads
console.log("Backend stopped");
},
});

Backend actions can push updates to the frontend:

handler: async (ctx, input) => {
// Do some work...
ctx.emit({ event: "task.completed", payload: { taskId: "123" } });
return { success: true };
},

The frontend receives this via useHostEvent("instrument.event", handler).

The ActionSchema type supports these JSON schema types:

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;
};

Schemas are optional but recommended — they serve as documentation and will enable validation in future SDK versions.

Backend actions are great for logic, but you also need persistent data. In the next tutorial you’ll learn about the three storage tiers.