Skip to content

Tutorial 6: Events & Hooks

Tango’s event system lets instruments react to changes in the host app — new sessions starting, stream output, stage changes, tool approvals, and more.

The primary way to subscribe to events in React:

import { useHostEvent } from "tango-api";
useHostEvent("snapshot.update", (snapshot) => {
console.log("Processes:", snapshot.processes.length);
console.log("Tasks:", snapshot.tasks.length);
});

The hook automatically subscribes on mount and unsubscribes on unmount. The handler should be wrapped in useCallback to avoid unnecessary resubscriptions:

import { useCallback } from "react";
useHostEvent("session.ended", useCallback((payload) => {
console.log(`Session ${payload.sessionId} ended with code ${payload.exitCode}`);
}, []));

Fires periodically with the current state of all Claude processes and tasks.

useHostEvent("snapshot.update", useCallback((snapshot) => {
// snapshot.processes — running Claude processes
// snapshot.tasks — active tasks
// snapshot.subagents — spawned subagents
// snapshot.eventCount — total events processed
}, []));

Fires for every stream event from a Claude session. This is the raw output stream.

useHostEvent("session.stream", useCallback((payload) => {
const { sessionId, event } = payload;
// event is a ClaudeStreamEvent — contains assistant messages, tool calls, etc.
}, []));

Fires when a temporary session ID is replaced with the real one (happens right after session spawn).

useHostEvent("session.idResolved", useCallback((payload) => {
console.log(`${payload.tempId}${payload.realId}`);
}, []));

Fires when a Claude session terminates.

useHostEvent("session.ended", useCallback((payload) => {
console.log(`Session ${payload.sessionId} exited with code ${payload.exitCode}`);
}, []));

Fires when Claude wants to use a tool and is waiting for user approval.

useHostEvent("tool.approval", useCallback((approval) => {
// approval: ToolApprovalRequest
// Contains tool name, arguments, session info
}, []));

Fires when any instrument emits a custom event. This is the inter-panel and inter-instrument communication channel.

useHostEvent("instrument.event", useCallback((payload) => {
// payload.instrumentId — which instrument emitted this
// payload.event — event name (e.g. "item.selected")
// payload.payload — arbitrary data
if (payload.instrumentId !== api.instrumentId) return;
if (payload.event === "item.selected") {
// handle it
}
}, [api]));

Fires when the user opens a new stage (project folder).

useHostEvent("stage.added", useCallback((payload) => {
console.log(`New stage: ${payload.path}`);
}, []));

Fires when a stage is removed.

useHostEvent("stage.removed", useCallback((payload) => {
console.log(`Stage removed: ${payload.path}`);
}, []));

Fires when the user switches to a different stage. Provides full git/VCS context for the newly selected stage.

useHostEvent("stage.selected", useCallback((stageInfo) => {
// stageInfo.path — stage directory path
// stageInfo.branch — current git branch (or null)
// stageInfo.headSha — HEAD commit SHA (or null)
// stageInfo.hasVersionControl — true if git/vcs repo
// stageInfo.hasChanges — true if any uncommitted changes
// stageInfo.additions — total line additions
// stageInfo.deletions — total line deletions
}, []));

Fires when the active stage’s metadata is refreshed without switching stages — e.g. after a commit, when files change during a session, or when the commit dialog loads fresh context. Same StageInfo payload as stage.selected.

useHostEvent("stage.updated", useCallback((stageInfo) => {
// Same shape as stage.selected
console.log(`Stage updated: ${stageInfo.branch}@${stageInfo.headSha?.slice(0, 7)}`);
}, []));

Fires when a connector’s authorization state changes.

useHostEvent("connector.auth.changed", useCallback((session) => {
// session: ConnectorAuthSession
}, []));

Fires when an agent-driven PR review changes status.

useHostEvent("pullRequest.agentReviewChanged", useCallback((payload) => {
console.log(`PR #${payload.number} review: ${payload.status}`);
// status: "running" | "completed" | "failed" | "stale"
}, []));

Use api.emit() to send events that other panels (or other instruments) can receive:

const api = useInstrumentApi();
// Emit a custom event
api.emit({
event: "item.selected",
payload: { itemId: "abc-123" },
});

The event is delivered as an instrument.event to all subscribers. The instrumentId field is added automatically.

Backend actions can also emit events:

// In backend.ts
handler: async (ctx, input) => {
// ... do work ...
ctx.emit({
event: "processing.complete",
payload: { resultCount: 42 },
});
return { success: true };
},

If you’re not using React, use the events API directly:

// Subscribe — returns an unsubscribe function
const unsubscribe = api.events.subscribe("snapshot.update", (snapshot) => {
console.log(snapshot.processes.length, "processes");
});
// Later: unsubscribe
unsubscribe();

Here’s a complete instrument that monitors active sessions in real time:

import { useCallback, useState } from "react";
import {
defineReactInstrument, useInstrumentApi, useHostEvent,
UIRoot, UIPanelHeader, UISection, UICard,
UIBadge, UIGroupList, UIGroupItem, UIEmptyState,
} from "tango-api";
type SessionLog = {
sessionId: string;
status: "active" | "ended";
exitCode?: number;
};
function SidebarPanel() {
const api = useInstrumentApi();
const [sessions, setSessions] = useState<Map<string, SessionLog>>(new Map());
// Track session starts from snapshots
useHostEvent("snapshot.update", useCallback((snapshot) => {
setSessions((prev) => {
const next = new Map(prev);
for (const task of snapshot.tasks) {
if (task.sessionId && !next.has(task.sessionId)) {
next.set(task.sessionId, {
sessionId: task.sessionId,
status: "active",
});
}
}
return next;
});
}, []));
// Track session ends
useHostEvent("session.ended", useCallback((payload) => {
setSessions((prev) => {
const next = new Map(prev);
next.set(payload.sessionId, {
sessionId: payload.sessionId,
status: "ended",
exitCode: payload.exitCode,
});
return next;
});
}, []));
const sessionList = Array.from(sessions.values());
return (
<UIRoot>
<UIPanelHeader
title="Session Monitor"
rightActions={
<UIBadge
label={`${sessionList.filter((s) => s.status === "active").length} active`}
tone="info"
/>
}
/>
<UISection>
{sessionList.length === 0 ? (
<UIEmptyState title="No sessions" description="Sessions will appear here as they start." />
) : (
<UIGroupList>
{sessionList.map((session) => (
<UIGroupItem
key={session.sessionId}
title={session.sessionId.slice(0, 8)}
subtitle={session.status}
meta={
<UIBadge
label={session.status}
tone={session.status === "active" ? "success" : "neutral"}
/>
}
/>
))}
</UIGroupList>
)}
</UISection>
</UIRoot>
);
}
export default defineReactInstrument({
defaults: { visible: { sidebar: true } },
panels: { sidebar: SidebarPanel },
});
EventFires when
snapshot.updatePeriodically, with all process/task state
session.streamEach stream chunk from a Claude session
session.idResolvedTemp ID → real ID mapping is known
session.endedA session terminates
tool.approvalClaude requests tool permission
instrument.eventAny instrument emits a custom event
stage.addedUser opens a stage
stage.removedA stage is removed
stage.selectedUser switched to a different stage
stage.updatedActive stage metadata refreshed
connector.auth.changedConnector auth state changes
pullRequest.agentReviewChangedPR review status changes

You’ve now covered the full instrument development loop: UI, multiple panels, backend logic, storage, and events. Head to the Reference section for complete API documentation.