Get Started
Install, wrap, and run — your first SNA app in 5 minutes
Build an AI app in 5 minutes. No API keys. No infrastructure. Just SKILL.md and Claude Code.
What is SNA?
SNA (Skills-Native Application) is an app framework where Claude Code is the runtime. Traditional AI apps call an LLM API and parse the response. SNA flips this. Claude Code itself executes the logic, and your app reacts to the results in real time.
Traditional: your code → LLM API → parse → actSNA: SKILL.md → Claude Code → scripts → SQLite → SSE → UIPrerequisites
- Node.js 18+
- Claude Code subscription
- pnpm (recommended)
Run the sample
The fastest way to see SNA in action is the hello-sna sample: a complete working app with a skill, typed client, and real-time event display.
git clone https://github.com/neuradex/snacd sna/samples/hello-snanpm installnpx sna upThat's it. The browser opens, and you see a "Say Hello" button. Click it, and Claude Code runs the skill. Events flow into the UI in real time.
What just happened?
sna up started two things: the SDK server (handles events, agent sessions, chat) and the Vite dev server (your app). SnaProvider in the React app auto-discovered the SDK server and connected to it.
What's in the sample?
| File | What it does |
|---|---|
| .claude/skills/hello/SKILL.md | The skill. Claude Code reads and executes this |
| src/App.tsx | React UI: "Say Hello" button + real-time event log |
| src/sna-client.ts | Typed client, generated by sna gen client |
| .claude/settings.json | Permission hook that relays Claude's permission requests to the GUI |
Start your own app
The easiest way. Install the SNA Builder plugin in Claude Code. It scaffolds a complete SNA project for you.
claude mcp add sna-builderThen just tell Claude what you want to build. The plugin knows all SNA conventions (SKILL.md structure, event protocol, DB separation, typed client) and applies them automatically.
Now let's look at what's inside a skill file.
Next: Your First Skill →Your First Skill
Build a skill and see the event pipeline in action
Let's build a "hello" skill, the simplest possible skill that demonstrates the full SNA loop: invoke → execute → emit → display.
What you'll build
Tell Claude Code "say hello" and a skill runs, emitting events that appear in your GUI in real time. One file, zero configuration.
Create the skill file
Skills live in .claude/skills/<name>/SKILL.md. Create this file:
---description: Say hello and demonstrate the event pipeline---## Instructions1. Emit a start event2. Greet the user3. Emit a complete eventUse the emit script for each event:```bashnode node_modules/@sna-sdk/core/dist/scripts/emit.js \ --skill hello --type start --message "Starting hello skill..."node node_modules/@sna-sdk/core/dist/scripts/emit.js \ --skill hello --type complete --message "Hello! Welcome to SNA."```The frontmatter (description) tells Claude Code what this skill does. The instructions section is what Claude Code actually executes.
Emit events
Every skill follows the same event protocol: start → milestone/progress → complete (or error). This is how the GUI knows what's happening.
Why events? Skill execution can take minutes. The GUI can't just wait. It needs to show progress as it happens. Events push updates in real time via SSE, so the user always knows what's going on.
Run it
Open the chat panel (click the floating button or press ⌘.) and type:
/helloClaude Code reads the SKILL.md, runs the emit commands, and your GUI updates in real time.
What happened under the hood
emit.js → writes to data/sna.db → SDK server reads DB → SSE stream → useSkillEvents hook → UI updatesThe SDK owns this entire pipeline. Your app never touches event storage or delivery. It just subscribes and reacts. This separation is intentional: the SDK guarantees event reliability so you can focus on your app's logic.
Add typed arguments
Make the skill accept a name parameter by adding sna.args to the frontmatter:
---description: Say hello to someonesna: args: name: type: string required: true description: Name of the person to greet---## Instructions1. Emit start2. Greet the user by their name: {{name}}3. Emit complete with the greeting messageSupported types: string, number, boolean, string[], number[]. The frontmatter is stripped before the skill content reaches Claude, so it costs zero context tokens.
Now generate a typed client so your GUI can call this skill with compile-time safety:
npx sna gen client --out src/sna-client.tsLet's connect this to a button in the next section.
Next: Connect the GUI →Connect the GUI
Call skills from React with typed, compile-time safety
You have a skill that runs in Claude Code. Now let's call it from a React button and watch the events flow into your UI.
Generate the typed client
If you haven't already, generate the client from your SKILL.md files:
npx sna gen client --out src/sna-client.tsThis reads the sna.args from every SKILL.md in .claude/skills/ and generates a typed TypeScript file. Every argument is checked at compile time. If a skill expects a number, you can't pass a string. No other agent framework does this.
Use useSnaClient
import { useSnaClient } from "@sna-sdk/react/hooks";import { bindSkills } from "./sna-client";function HelloButton() { const { skills } = useSnaClient({ bindSkills }); return ( <button onClick={() => skills.hello({ name: "World" })}> Say Hello </button> );}skills.hello() is fully typed. Autocomplete shows available skills and their required arguments. The skill runs in a background session, so the main chat stays free for conversation.
Subscribe to events
import { useSkillEvents } from "@sna-sdk/react/hooks";function SkillStatus() { const { events, isRunning, connected } = useSkillEvents({ onComplete: () => { // Refresh your data when a skill finishes mutate("/api/data"); }, }); const latest = events[events.length - 1]; return ( <div> {isRunning && <Spinner />} {latest && <p>{latest.message}</p>} </div> );}useSkillEvents subscribes to the SSE stream from the SDK server. Events arrive as they're emitted. No polling, no delay.
The full loop
Button click → skills.hello({ name: "World" }) → SDK spawns background Claude Code session → Claude reads .claude/skills/hello/SKILL.md → Claude runs emit.js → writes to SQLite → SDK server streams event via SSE → useSkillEvents receives event → UI updatesThis is the core of SNA. Four trigger sources (GUI click, terminal command, agent decision, programmatic call) all hit the same pipeline. Zero extra implementation per trigger.
Next: Go Deeper →Go Deeper
Multi-session, pipelines, plugins, and publishing
You've built a skill and connected it to the GUI. Here's what else SNA can do.
Multi-session
By default, skills invoked from the GUI run in background sessions. The main chat stays free for conversation while skills execute independently.
// useSnaClient runs skills in background sessions automaticallyconst { skills } = useSnaClient({ bindSkills });await skills.formFill({ sessionId: 123 });// Or use the lower-level useSna hook for manual controlconst { runSkillInBackground } = useSna();const result = await runSkillInBackground("form-fill");Skill pipelines
Skills can chain other skills. Each step is a full Claude Code invocation with its own context. Instruct one skill to call another by referencing it in the SKILL.md instructions.
SNA Builder plugin
The SNA Builder plugin teaches Claude Code the SDK conventions automatically. When you open a new agent session, the plugin's CLAUDE.md instructs it to follow SNA patterns: emit events correctly, respect DB separation, and use proper frontmatter.
# Install from the marketplaceclaude mcp add sna-builderSkill execution locking
The SDK intentionally does not provide execution locking. Why? Locking requires domain knowledge. Your app knows which resources conflict. A form-filling app needs per-browser-session locks; a data pipeline needs per-table locks. The SDK can't guess this.
Implement locking at the app level:
const [running, setRunning] = useState(false);async function handleFill(sessionId: number) { if (running) return; // app-level lock setRunning(true); try { await skills.formFill({ sessionId }); } finally { setRunning(false); }}Publishing
SNA apps can be packaged in any form: share a GitHub repo, build with Tauri, bundle as Electron, or publish on the SNA Marketplace for others to discover.
SNA Marketplace ↗Architecture
DB separation, event pipeline, and responsibility boundary
Overview
SKILL.md → Claude Code → scripts → SQLite → SSE → UI| Package | npm | Role |
|---|---|---|
| packages/core | @sna-sdk/core | Server runtime, DB, CLI, event pipeline, emit script, code generation |
| packages/react | @sna-sdk/react | SnaProvider, hooks (useSna, useSnaClient, useSkillEvents), chat UI, stores |
| packages/client | @sna-sdk/client | Typed WebSocket client for browser and Node.js — sessions, agent, permissions |
| packages/testing | @sna-sdk/testing | sna-test CLI — isolated Claude instances with mock Anthropic API |
DB Separation
| Database | Owner | Contents |
|---|---|---|
| data/sna.db | @sna-sdk/core | chat_sessions, chat_messages, skill_events |
| data/<app>.db | Application | App-specific tables |
Why two databases? The SDK must own the event pipeline entirely so skills never interfere with each other. Your app only manages its own domain data. Use PostgreSQL, Supabase, Prisma, or anything you want. The SDK doesn't care.
chat_sessions (id TEXT PK, label, type, created_at)chat_messages (id INTEGER PK, session_id FK, role, content, skill_name, meta, created_at)skill_events (id INTEGER PK, session_id FK, skill, type, message, data, created_at)Event Pipeline
Skill execution → emit.js writes to data/sna.db → SDK standalone server reads sna.db → GET /events (SSE) streams to frontend → useSkillEvents hook updates UIWhy SSE instead of polling? Skill execution can take minutes. Polling wastes bandwidth and adds latency. SSE pushes events the instant they're emitted, so the UI is always in sync.
Context-Aware emit.js
emit.js checks for the SNA_SESSION_ID environment variable. When present (SDK-managed session), events are written to sna.db with a session foreign key. When absent (e.g., running from terminal), events go to console only, with no DB write. This makes skills safe to run anywhere.
Event Types
| Type | When |
|---|---|
| invoked | SDK records on /agent/start (before Claude boots) |
| start | First thing a skill emits |
| progress | Incremental updates inside loops |
| milestone | Significant checkpoint reached |
| complete | Skill finished. Triggers frontend auto-refresh |
| error | Something failed |
Responsibility Boundary
Your app owns
- App-specific DB tables and queries
- API routes for domain data
- Skill definitions in .claude/skills/
- Mounting SnaProvider and configuring hooks
- Skill execution locking (if needed)
The SDK owns
- Event pipeline (emit → DB → SSE → hooks)
- Chat session management
- Agent process lifecycle (spawn, kill, monitor)
- Permission hook relay
- Typed client code generation
API Reference
HTTP endpoints, React hooks, SnaProvider props, and CLI
SDK Server Endpoints
The SDK runs a standalone Hono server (default port 3099). Your app discovers it via GET /api/sna-port.
| Endpoint | Description |
|---|---|
| GET /health | Health check |
| GET /sessions | List all sessions |
| POST /sessions | Create a new session { label?, cwd?, meta? } |
| DELETE /sessions/:id | Remove a session (cannot remove 'default') |
| POST /start?session=<id> | Start agent { provider?, prompt?, model?, permissionMode?, configDir?, force?, history? } |
| POST /send?session=<id> | Send message { message?, images? } |
| GET /events?session=<id>&since=<cursor> | SSE stream of agent events |
| POST /kill?session=<id> | Kill agent process (session stays) |
| POST /restart?session=<id> | Kill + re-spawn with merged config |
| POST /resume?session=<id> | Spawn with DB history injected { prompt?, model?, configDir? } |
| POST /interrupt?session=<id> | Interrupt current turn (process stays alive) |
| POST /set-model?session=<id> | Change model at runtime { model } |
| POST /set-permission-mode?session=<id> | Change permission mode at runtime { permissionMode } |
| GET /status?session=<id> | Agent status — alive, state, eventCount, lastMessage |
| POST /permission-respond?session=<id> | Approve/deny pending permission { approved } |
| POST /run-once | One-shot: spawn → wait → result → cleanup { message, model?, permissionMode? } |
| GET /events?since=<id> | SSE stream of skill events (legacy — prefer WS) |
| POST /emit | Write a skill event { skill, type, message, data? } |
React Hooks
useSna()
The all-in-one hook. Returns skill events, agent session control, chat state, and skill execution methods.
const { events, // SkillEvent[] connected, // boolean — SSE connection status isRunning, // boolean — any skill currently executing agent, // { alive, connected, send, start } chat, // { isOpen, messages, toggle, addMessage } runSkill, // (name: string) => Promise<void> runSkillInBackground // (name: string) => Promise<SkillResult>} = useSna();useSnaClient({ bindSkills })
Wraps useSna with a typed skill client generated by sna gen client. Provides compile-time checked skill invocation.
import { bindSkills } from "./sna-client";const { skills, events, isRunning } = useSnaClient({ bindSkills });await skills.formFill({ sessionId: 123 }); // ← type-safeuseSkillEvents(options?)
Low-level event subscription. Use this when you only need events without agent/chat features.
const { events, connected, isRunning, latestBySkill, clearEvents } = useSkillEvents({ onComplete: (event) => { /* refresh data */ }, onError: (event) => { /* handle failure */ },});SnaProvider Props
| Prop | Type | Description |
|---|---|---|
| snaUrl | string | Override SDK server URL (auto-discovered by default) |
| defaultOpen | boolean | Open chat panel on mount (default: false) |
| dangerouslySkipPermissions | boolean | Bypass Claude permission prompts (default: false) |
| headless | boolean | Provide context only, no built-in UI (default: false) |
| initialSessionId | string | Session ID to start with (default: "default") |
CLI Commands
| Command | Description |
|---|---|
| sna up | Start all services (DB, API server, dev server) |
| sna down | Stop all services |
| sna status | Show running services and ports |
| sna restart | Stop and start all services |
| sna init | Initialize .claude/settings.json and skills directory |
| sna gen client --out <path> | Generate typed TypeScript client from SKILL.md files |
Emit Script
node node_modules/@sna-sdk/core/dist/scripts/emit.js \ --skill <name> \ --type <start|progress|milestone|complete|error> \ --message "..." \ [--data '{"key": "value"}']WebSocket Protocol
Full WS message reference — requests, responses, and push events
Connect to ws://host:port/ws and exchange JSON messages. All SNA functionality is available over a single persistent connection with auto-reconnect support.
Protocol Format
// Client → Server: request with optional rid{ type: "agent.start", rid: "1", session: "default", prompt: "Hello" }// Server → Client: response (mirrors rid){ type: "agent.start", rid: "1", status: "started", sessionId: "default" }// Server → Client: error{ type: "error", rid: "1", message: "Session not found" }// Server → Client: push (no rid — auto-sent by server){ type: "sessions.snapshot", sessions: [...] }{ type: "agent.event", session: "default", cursor: 42, event: { type: "assistant", ... } }Auto-Push Events (no subscribe needed)
| Type | Payload | When |
|---|---|---|
| sessions.snapshot | { sessions: SessionInfo[] } | On connect + any session change |
| session.lifecycle | { session, state: 'started'|'resumed'|'killed'|'exited'|'crashed'|'restarted', code? } | Agent process lifecycle event |
| session.state-changed | { session, state: SessionState, agentStatus: AgentStatus } | Agent state transition (idle/waiting/processing/permission) |
| session.config-changed | { session, config: StartConfig } | Start config updated (model, permissionMode, etc.) |
| agent.event | { session, cursor: number, event: AgentEvent } | Agent event stream (requires agent.subscribe) |
| permission.request | { session, request: { tool_name, tool_input }, createdAt } | Tool permission needed (requires permission.subscribe) |
Session Messages
| Type | Params | Response |
|---|---|---|
| sessions.create | { label?, cwd?, meta? } | { status: 'created', sessionId, label, meta } |
| sessions.list | {} | { sessions: SessionInfo[] } |
| sessions.update | { session, label?, meta?, cwd? } | { status: 'updated', session } |
| sessions.remove | { session } | { status: 'removed' } |
Agent Messages
| Type | Key Params | Response |
|---|---|---|
| agent.start | { session?, provider?, prompt?, model?, permissionMode?, configDir?, force?, history? } | { status: 'started'|'already_running', sessionId } |
| agent.send | { session?, message, images?, meta? } | { status: 'sent' } |
| agent.kill | { session? } | { status: 'killed'|'no_session' } |
| agent.restart | { session?, model?, permissionMode?, configDir? } | { status: 'restarted', sessionId } |
| agent.resume | { session?, prompt?, model?, permissionMode?, configDir? } | { status: 'resumed', historyCount } |
| agent.interrupt | { session? } | { status: 'interrupted'|'no_session' } |
| agent.set-model | { session?, model } | { status: 'updated', model } |
| agent.set-permission-mode | { session?, permissionMode } | { status: 'updated', permissionMode } |
| agent.status | { session? } | { alive, state, agentStatus, eventCount, messageCount, lastMessage, config } |
| agent.subscribe | { session?, since? } | starts agent.event push |
| agent.unsubscribe | { session? } | stops agent.event push |
| agent.run-once | { message, model?, permissionMode?, timeout? } | { result: string, usage } |
Permission Messages
| Type | Params | Response |
|---|---|---|
| permission.subscribe | {} | starts permission.request push |
| permission.unsubscribe | {} | stops permission.request push |
| permission.respond | { session?, approved: boolean } | { status: 'approved'|'denied' } |
| permission.pending | { session? } | { pending: { request, createdAt } | null } |
AgentEvent Types
| type | Key Fields | Cursor |
|---|---|---|
| init | message, data: { sessionId, model } | persisted |
| thinking | message (extended thinking text) | persisted |
| assistant_delta | delta (text chunk), index | transient (cursor = -1) |
| assistant | message (full assistant text) | persisted |
| tool_use | message (tool name), data: { toolName, input, id } | persisted |
| tool_result | message (truncated), data: { toolUseId, isError } | persisted |
| user_message | message (user text sent via agent.send) | persisted |
| interrupted | message, data: { durationMs, costUsd } | persisted |
| complete | message, data: { durationMs, costUsd, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, contextWindow, model } | persisted |
| error | message | persisted |
Subscribing to Agent Events
// Subscribe from cursor 0 (all history) or omit for live-onlyws.send(JSON.stringify({ type: "agent.subscribe", rid: "1", session: "default", since: 0 }));// Receive eventsws.onmessage = ({ data }) => { const msg = JSON.parse(data); if (msg.type === "agent.event") { const { session, cursor, event } = msg; if (event.type === "assistant_delta") console.log(event.delta); // streaming text if (event.type === "assistant") console.log(event.message); // final text if (event.type === "complete") console.log("done", event.data.costUsd); }};SessionInfo Shape
interface SessionInfo { id: string label: string alive: boolean state: "idle" | "waiting" | "processing" | "permission" agentStatus: "idle" | "busy" | "disconnected" cwd: string meta: Record<string, unknown> | null config: StartConfig | null // last start config ccSessionId: string | null // Claude Code's own session id eventCount: number // persisted event cursor messageCount: number // chat_messages row count lastMessage: { role, content, created_at } | null createdAt: number // Unix ms lastActivityAt: number // Unix ms}interface StartConfig { provider: string // e.g. "claude-code" model: string // e.g. "claude-sonnet-4-6" permissionMode?: string // e.g. "default" | "bypassPermissions" configDir?: string // isolated CLAUDE_CONFIG_DIR extraArgs?: string[]}@sna-sdk/client
Typed WebSocket client for browser and Node.js apps
A typed WebSocket client for browser and Node.js apps. Handles request/response correlation via rid, auto-reconnect with re-subscription, and typed push event routing.
npm install @sna-sdk/clientBasic Setup
import { SnaClient } from "@sna-sdk/client";const sna = new SnaClient({ url: "ws://localhost:3099/ws" });// Monitor connectionsna.onConnectionStatus((status) => console.log("SNA:", status));// Snapshot arrives immediately on connect (no subscribe needed)sna.sessions.onSnapshot((sessions) => { console.log("Sessions:", sessions);});sna.connect();SnaClient Methods
| Method | Description |
|---|---|
| connect() | Open WS connection |
| disconnect() | Close WS connection |
| onConnectionStatus(cb) | Connection status: 'connected'|'disconnected'|'reconnecting' |
sna.sessions API
| Method | Description |
|---|---|
| create(opts?) | Create session { label?, cwd?, meta? } |
| remove(session) | Remove session (not 'default') |
| update(session, opts) | Update label, meta, or cwd |
| list() | List all sessions |
| onSnapshot(cb) | Subscribe to sessions.snapshot push |
| onConfigChanged(cb) | Subscribe to session.config-changed push |
sna.agent API
| Method | Description |
|---|---|
| start(session, config?) | Start agent { provider?, prompt?, model?, permissionMode?, configDir?, force?, history? } |
| send(session, message, meta?) | Send user message (text or content blocks) |
| kill(session) | Kill agent process |
| restart(session, config?) | Kill + re-spawn with merged config |
| resume(session, opts?) | Spawn with DB history + optional configDir override |
| interrupt(session) | Interrupt current turn (process stays) |
| getStatus(session) | Get alive, state, eventCount, lastMessage |
| setModel(session, model) | Change model at runtime |
| setPermissionMode(session, mode) | Change permission mode at runtime |
| subscribe(session, opts?) | Start agent.event push (since? for history replay) |
| unsubscribe(session) | Stop agent.event push |
| onEvent(cb) | Global handler: { session, cursor, event } |
Permission Handling
// Subscribe to permission requests (all sessions)await sna.agent.subscribePermissions();// Handle incoming requestssna.agent.onPermissionRequest(({ session, request }) => { const { tool_name, tool_input } = request; const approved = confirm(`Allow ${tool_name}?`); sna.agent.respondPermission(session, approved);});// Other permission methodsawait sna.agent.getPendingPermissions(session); // get pending requestawait sna.agent.unsubscribePermissions(); // stop push@sna-sdk/testing
sna-test CLI and mock Anthropic API for local testing
Local testing toolkit for SNA. Provides isolated Claude Code instances (each with its own CLAUDE_CONFIG_DIR) and a mock Anthropic API that responds like the real API — including streaming tool_use.
npm install -D @sna-sdk/testingsna-test CLI
| Command | Description |
|---|---|
| sna-test claude [args...] | Launch Claude Code in an isolated instance with mock API. All args passed through (e.g. -p 'Hello', --model ...) |
| sna-test ls | List all instances with name, command, mode, status |
| sna-test logs <name> [-f] [--api] | View formatted logs. -f follows live output. --api shows mock API request/response log |
| sna-test rm <name|--all> | Remove instance(s) and their working directory |
Instance Isolation
Each sna-test claude invocation creates a named instance (e.g. happy-panda) under .sna/instances/<name>/. The instance has its own CLAUDE_CONFIG_DIR with pre-seeded config that skips all onboarding dialogs (theme picker, API key approval, workspace trust).
Mock Anthropic API
The mock API behaves like the real Anthropic Messages API. It returns streaming responses with proper SSE format. To trigger a tool_use response, include [tool:ToolName] anywhere in your user message.
# Interactive Claude with mock API (asks permission for tools)sna-test claude# One-shot with tool trigger (Claude needs a tool defined)sna-test claude -p "Please create a file [tool:Write]"# View what the mock API received/respondedsna-test logs happy-panda --apiProgrammatic API
import { startMockAnthropicServer } from "@sna-sdk/testing";const server = await startMockAnthropicServer();console.log(`Mock API running at ${server.url}`);// Listen to request/response log entriesserver.onLog((entry) => { console.log(entry.type, entry.model, entry.stopReason);});await server.stop();Configuration
Settings, server setup, database, and Vite config
Claude Code Settings
The permission hook relays Claude Code's tool-use permission requests to the GUI. Run sna init to set this up automatically, or add it manually:
{ "hooks": { "PermissionRequest": [{ "matcher": ".*", "hooks": [{ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/node_modules/@sna-sdk/core/dist/scripts/hook.js", "async": true }] }] }}Server Setup
Your server handles app-specific routes only. The SDK runs its own server for events and agent management. You just need one route for port discovery:
import { Hono } from "hono";import { snaPortRoute } from "@sna-sdk/core/server";const app = new Hono();// Your app routesapp.route("/api/targets", targetsRoutes);app.route("/api/sessions", sessionsRoutes);// Required: SnaProvider calls this to discover the SDK serverapp.get("/api/sna-port", snaPortRoute);Database Setup
Your app manages its own database. Do not include SDK tables:
function initSchema(db: Database.Database) { db.exec(` CREATE TABLE IF NOT EXISTS targets (...); CREATE TABLE IF NOT EXISTS sessions (...); -- NO skill_events, chat_sessions, or chat_messages here -- Those live in data/sna.db (SDK-managed) `);}Vite Configuration
For source-level development with linked SDK packages:
export default defineConfig({ resolve: { conditions: ["source"], // resolve SDK source directly — no build during dev dedupe: ["react", "react-dom"], // prevent duplicate React with link: packages }, server: { fs: { allow: [".", path.resolve(__dirname, "..")], }, }, optimizeDeps: { exclude: ["@sna-sdk/core", "@sna-sdk/react"], // skip pre-bundling linked packages },});