Skip to content

Tutorial 5: Storage

The SDK provides three storage tiers. Each is available on both the frontend (api.storage) and the backend (ctx.host.storage). All data is scoped per-instrument — instruments cannot access each other’s storage.

The simplest option. Store and retrieve JSON-serializable values by key.

Permission required: storage.properties

// Write
await api.storage.setProperty("lastOpenedTab", "settings");
await api.storage.setProperty("counter", 42);
await api.storage.setProperty("config", { theme: "dark", fontSize: 14 });
// Read
const tab = await api.storage.getProperty<string>("lastOpenedTab"); // "settings"
const count = await api.storage.getProperty<number>("counter"); // 42
const config = await api.storage.getProperty<{ theme: string }>("config"); // { theme: "dark", ... }
// Delete
await api.storage.deleteProperty("counter");
// Returns null for missing keys
const missing = await api.storage.getProperty("nonexistent"); // null
  • UI state that should survive reloads (selected tab, scroll position, collapsed sections)
  • Small configuration values
  • Cross-panel state synchronization (as seen in Tutorial 3)

Read and write files in a sandboxed directory. Useful for larger blobs, exports, or imported data.

Permission required: storage.files

// Write a text file
await api.storage.writeFile("notes/meeting.md", "# Meeting Notes\n\n- Item 1\n- Item 2");
// Read it back
const content = await api.storage.readFile("notes/meeting.md"); // string
// Write binary data (base64)
await api.storage.writeFile("images/logo.png", base64String, "base64");
// Read binary data
const b64 = await api.storage.readFile("images/logo.png", "base64");
// List files in a directory
const files = await api.storage.listFiles("notes/"); // ["notes/meeting.md"]
// List all files
const allFiles = await api.storage.listFiles(); // ["notes/meeting.md", "images/logo.png"]
// Delete a file
await api.storage.deleteFile("notes/meeting.md");
  • Markdown documents, JSON exports, CSV data
  • Binary content (images, PDFs)
  • Anything too large for a property value
  • Data you want to organize in a directory structure

Full relational database. Each instrument gets its own SQLite database (or multiple named databases).

Permission required: storage.db

await api.storage.sqlExecute(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT DEFAULT 'todo',
created_at TEXT DEFAULT (datetime('now'))
)
`);
await api.storage.sqlExecute(
"INSERT INTO tasks (id, title, status) VALUES (?, ?, ?)",
["task-1", "Build docs", "in_progress"]
);
type Task = { id: string; title: string; status: string; created_at: string };
const tasks = await api.storage.sqlQuery<Task>(
"SELECT * FROM tasks WHERE status = ? ORDER BY created_at DESC",
["todo"]
);
// tasks: Task[]
const result = await api.storage.sqlExecute(
"UPDATE tasks SET status = ? WHERE id = ?",
["done", "task-1"]
);
// result: { changes: 1, lastInsertRowid: null }
await api.storage.sqlExecute("DELETE FROM tasks WHERE id = ?", ["task-1"]);

Pass a database name as the third argument:

// Uses a separate "analytics" database
await api.storage.sqlExecute(
"CREATE TABLE IF NOT EXISTS events (id TEXT, type TEXT, timestamp TEXT)",
[],
"analytics"
);
await api.storage.sqlQuery("SELECT * FROM events", [], "analytics");
  • Structured data with relationships
  • Data you need to query/filter/sort
  • Large datasets (thousands of records)
  • When you need transactions or constraints

Here’s a sidebar that uses all three tiers:

import { useEffect, useState } from "react";
import {
defineReactInstrument, useInstrumentApi,
UIRoot, UIPanelHeader, UISection, UICard,
UIButton, UIInput, UIBadge,
} from "tango-api";
type Note = { id: string; title: string; created_at: string };
function SidebarPanel() {
const api = useInstrumentApi();
const [notes, setNotes] = useState<Note[]>([]);
const [title, setTitle] = useState("");
const [viewCount, setViewCount] = useState(0);
useEffect(() => {
// Initialize SQLite table
api.storage.sqlExecute(`
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
`).then(() => loadNotes());
// Track page views with properties
api.storage.getProperty<number>("viewCount").then((count) => {
const next = (count ?? 0) + 1;
setViewCount(next);
api.storage.setProperty("viewCount", next);
});
}, [api]);
const loadNotes = async () => {
const rows = await api.storage.sqlQuery<Note>("SELECT * FROM notes ORDER BY created_at DESC");
setNotes(rows);
};
const addNote = async () => {
if (!title.trim()) return;
const id = crypto.randomUUID();
await api.storage.sqlExecute(
"INSERT INTO notes (id, title) VALUES (?, ?)",
[id, title]
);
// Also save as a markdown file
await api.storage.writeFile(`notes/${id}.md`, `# ${title}\n\nCreated: ${new Date().toISOString()}`);
setTitle("");
await loadNotes();
};
return (
<UIRoot>
<UIPanelHeader
title="Notes"
rightActions={<UIBadge label={`${viewCount} views`} tone="neutral" />}
/>
<UISection>
<UICard>
<UIInput value={title} placeholder="New note title" onInput={setTitle} />
<UIButton label="Add Note" variant="primary" onClick={addNote} />
</UICard>
</UISection>
<UISection title={`${notes.length} notes`}>
{notes.map((note) => (
<UICard key={note.id}>
<strong>{note.title}</strong>
<small>{note.created_at}</small>
</UICard>
))}
</UISection>
</UIRoot>
);
}
export default defineReactInstrument({
defaults: { visible: { sidebar: true } },
panels: { sidebar: SidebarPanel },
});

You can persist data, but you’re not yet reacting to what’s happening in Tango. In the next tutorial you’ll subscribe to host events like session streams and stage changes.