Skip to content

AI Overview

Everything you need to build a Tango instrument in one page. Each section links to a deeper reference.

An instrument has two optional parts:

  • Frontend (src/index.tsx) — React components rendered in panel slots inside Tango’s WebKit webview. Import from "tango-api".
  • Backend (src/backend.ts) — Code that runs in the host Bun process: data fetching, computation, file I/O, API calls, timers. Import from "tango-api/backend".

When do you need a backend? If your instrument just displays static UI or reads from built-in storage, frontend-only is fine. Add a backend when you need external APIs, periodic tasks, heavy processing, or anything you wouldn’t do in a browser.

  1. The frontend is dumb. It renders UI and responds to user actions. It does NOT fetch data, poll, or run timers.
  2. The backend is the brain. It owns all data fetching, scheduling, and side effects. It pushes data to the frontend via events.
  3. Panels cannot talk to each other directly. Each panel slot is a separate React root — they share NO state. All panel-to-panel communication MUST go through the backend.
  4. api.emit() (frontend) is ONLY for cross-instrument communication. To send data between your own panels, call a backend action, then have the backend use ctx.emit() to broadcast to all your panels.
  5. Every panel component must be wrapped in <UIRoot>. This injects the Tango theme.

Your instrument can render into up to 4 slots:

SlotWhere it appearsCommon use
sidebarLeft sidebarNavigation, lists, quick actions
firstCenter-left main areaPrimary content
secondCenter-rightSecondary/detail view
rightRight sidebarContext panels, settings

Enable slots in package.json under tango.instrument.panels. Only create panel components for slots you enable.

Panels cannot share React state. Use the backend as a hub:

Sidebar panel ──→ calls backend action ──→ Backend processes
├── ctx.emit("items.loaded", { items })
First panel ←── useHostEvent("instrument.event") listens
Second panel ←── useHostEvent("instrument.event") listens
// Sidebar: user clicks an item → call backend action
const selectItem = useInstrumentAction("selectItem");
<UIListItem onClick={() => selectItem({ id: item.id })} />
// Backend: fetch data and broadcast to all panels
actions: {
selectItem: {
handler: async (ctx, input) => {
const detail = await fetchDetail(input.id);
ctx.emit({ event: "item.selected", payload: { detail } });
return { ok: true };
},
},
},
// First panel: listen for the event
useHostEvent("instrument.event", useCallback((payload) => {
if (payload.event === "item.selected") {
setDetail(payload.payload.detail);
}
}, []));

NEVER use api.emit() to communicate between your own panels — it won’t work.

If you emit multiple events in rapid succession within a single action handler, React 18 may batch the state updates and only render the final state. Set loading state in the frontend before calling the action:

const [loading, setLoading] = useState(false);
const fetchTickets = useInstrumentAction("fetchTickets");
async function handleClick(id: string) {
setLoading(true); // Immediate local state
await fetchTickets({ id }); // Backend fetches and emits result
}
useHostEvent("instrument.event", useCallback((payload) => {
if (payload.event === "tickets.loaded") {
setTickets(payload.payload.tickets);
setLoading(false);
}
}, []));

Rule of thumb: Frontend owns loading/UI state. Backend owns data.


src/index.tsx must default-export a defineReactInstrument call:

import { defineReactInstrument } from "tango-api";
export default defineReactInstrument({
panels: {
sidebar: SidebarPanel,
first: MainPanel,
},
defaults: {
visible: { sidebar: true, first: true },
},
});
You want to…Use this
Access storage, sessions, actions, UI utilsuseInstrumentApi() — returns the full API object
Call a single backend actionuseInstrumentAction("actionName")
Run a Claude session with streaminguseSession({ id, persist? })
React to host eventsuseHostEvent("eventName", callback)
Read/write user settingsuseInstrumentSettings()
Check which panels are visibleusePanelVisibility()
const api = useInstrumentApi();
// Storage
await api.storage.getProperty<MyType>("key");
await api.storage.setProperty("key", value);
// Backend actions
const result = await api.actions.call("myAction", { input: "data" });
// Sessions
await api.sessions.start({ prompt: "Help me with...", cwd: "/path" });
// UI utilities
api.ui.openUrl("https://example.com");
const html = api.ui.renderMarkdown("# Hello");
// Cross-instrument events
api.emit({ event: "my-custom-event", payload: { data } });

Tango has a built-in debug console (toggle with Cmd+L). Use it instead of console.log — logs appear in the app with level, timestamp, and instrument source.

Frontend — use the useLogger() hook or api.logger:

import { useLogger } from "tango-api";
const logger = useLogger();
logger.info("Loaded items", { count: items.length });
logger.error("Failed to fetch", { error: err.message });
logger.warn("Retrying...");
logger.debug("Cache state", cacheMap);

Or via the API object:

const api = useInstrumentApi();
api.logger.info("Something happened");

Backend — use ctx.logger:

handler: async (ctx, input) => {
ctx.logger.info("Processing request", { input });
ctx.logger.error("External API failed", { status: 500 });
return { ok: true };
},

All four methods share the same signature: (message: string, ...args: unknown[]) => void. Extra args appear as expandable JSON detail in the debug console.

Deep dive: Frontend API →

All imported from "tango-api". Build layouts with tui-col and tui-row CSS classes.

Layout & structure: UIRoot (required wrapper), UIScrollArea, UIPanelHeader, UISection, UICard, UIContainer, UIFooter

Actions: UIButton (variants: primary, secondary, ghost, danger, success), UIIconButton, UILink

Data display: UIBadge (tones: neutral, info, success, warning, danger), UIKeyValue, UIMarkdownRenderer, UIInlineCode, UIIcon, UIEmptyState

Lists & groups: UIList + UIListItem, UIGroup (collapsible), UISelectionList, UITreeView

Forms: UIInput, UITextarea, UISelect, UIDropdown, UIToggle, UICheckbox, UIRadioGroup, UISegmentedControl

Navigation: UITabs

Deep dive: UI Components →

--tui-bg /* Main background */
--tui-bg-secondary /* Subtle background */
--tui-bg-card /* Card surfaces */
--tui-text /* Primary text */
--tui-text-secondary /* Muted text */
--tui-border /* Borders */
--tui-primary /* Accent color */

src/backend.ts must default-export a defineBackend call:

import { defineBackend, type InstrumentBackendContext } from "tango-api/backend";
export default defineBackend({
kind: "tango.instrument.backend.v2",
onStart: async (ctx) => {
ctx.logger.info("Instrument started");
},
onStop: async () => {
// Clean up resources
},
actions: {
fetchItems: {
input: { type: "object", properties: { query: { type: "string" } } },
output: { type: "object", properties: { items: { type: "array" } } },
handler: async (ctx, input) => {
const items = await fetchFromApi(input.query);
return { items };
},
},
},
});
  • ctx.emit({ event, payload? }) — Push data to the frontend
  • ctx.logger.info/warn/error(msg) — Structured logging
  • ctx.instrumentId — Your instrument’s ID
  • ctx.host.storage — Same storage API as frontend
  • ctx.host.sessions — Start Claude sessions from the backend
  • ctx.host.settings — Read instrument settings

Core pattern: Backend pushes, frontend listens

Section titled “Core pattern: Backend pushes, frontend listens”
// Backend
actions: {
refresh: {
handler: async (ctx) => {
const data = await fetchLatestData();
ctx.emit({ event: "data.updated", payload: { data } });
return { ok: true };
},
},
},
// Frontend
function MainPanel() {
const [data, setData] = useState(null);
const refresh = useInstrumentAction("refresh");
useHostEvent("instrument.event", useCallback((payload) => {
if (payload.event === "data.updated") {
setData(payload.payload.data);
}
}, []));
useEffect(() => { refresh(); }, []);
return (
<UIRoot>
<UIPanelHeader title="My Data" rightActions={
<UIIconButton icon="refresh" label="Refresh" onClick={() => refresh()} />
} />
{data ? <DataView data={data} /> : <UIEmptyState title="Loading..." />}
</UIRoot>
);
}

Deep dive: Backend API →

For periodic data updates, use the built-in background refresh instead of rolling your own timer:

// In package.json → tango.instrument
"backgroundRefresh": { "intervalMs": 60000 }
onBackgroundRefresh: async (ctx) => {
const data = await fetchLatestData();
ctx.emit({ event: "data.refreshed", payload: data });
},

Tango manages the timer lifecycle — pauses when idle, resumes when active.

Deep dive: Background Refresh →


Declared in package.json under tango.instrument.permissions. You MUST declare every permission your code uses — Tango throws a runtime error otherwise.

You use this in your codeRequired permission
api.storage.getProperty(), setProperty(), deleteProperty()storage.properties
api.storage.readFile(), writeFile(), deleteFile(), listFiles()storage.files
api.storage.sqlQuery(), sqlExecute()storage.db
api.sessions.start(), query(), sendFollowUp(), kill(), useSession()sessions
useHostEvent("session.stream"), useHostEvent("session.ended")sessions
api.stages.list(), api.stages.active()stages.read
useHostEvent("stage.added"), useHostEvent("stage.removed")stages.observe
api.connectors.listStageConnectors(), isAuthorized()connectors.read
api.connectors.getCredential()connectors.credentials.read
api.connectors.connect(), disconnect()connectors.connect

Deep dive: Permissions →


Define a settings schema for user configuration:

// In package.json → tango.instrument
"settings": [
{ "key": "apiKey", "type": "string", "title": "API Key", "required": true, "secret": true },
{ "key": "maxResults", "type": "number", "title": "Max Results", "default": 10, "min": 1, "max": 100 },
{ "key": "enabled", "type": "boolean", "title": "Auto-refresh", "default": false },
{ "key": "format", "type": "select", "title": "Output", "options": [{"value":"json","label":"JSON"},{"value":"csv","label":"CSV"}] }
]

After changing settings, run bun run sync to regenerate tango-env.d.ts with typed keys.

const { values, setValue, loading } = useInstrumentSettings();
if (values?.apiKey) { /* configured */ }

Full manifest lives in package.json under tango.instrument:

{
"tango": {
"instrument": {
"id": "my-instrument",
"name": "My Instrument",
"description": "What it does",
"category": "developer-tools",
"group": "Custom",
"runtime": "react",
"entrypoint": "./dist/index.js",
"backendEntrypoint": "./dist/backend.js",
"hostApiVersion": "2.0.0",
"launcher": {
"sidebarShortcut": { "enabled": true, "label": "My Tool", "icon": "puzzle", "order": 50 }
},
"panels": { "sidebar": true, "first": true, "second": false, "right": false },
"permissions": [],
"settings": []
}
}
}

Deep dive: Manifest →


Terminal window
bun run dev # Build + watch + hot-reload (Tango must be running on port 4243)
bun run build # One-off production build → dist/
bun run validate # Check manifest, entry points, permissions
bun run sync # Regenerate tango-env.d.ts from settings schema
  • Edits to src/ auto-rebuild and hot-reload — no restart needed
  • Changes to package.json or lockfile trigger a fresh bun install + rebuild
  • Your instrument shows a [dev] badge in Tango’s sidebar while dev mode is active

Deep dive: CLI →


  1. Push to a GitHub repository
  2. Include a tango.json at the repo root
  3. Users add your repo as a source in Tango and install from the browse panel