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 → act
SNA: SKILL.md → Claude Code → scripts → SQLite → SSE → UI

Prerequisites

  • 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/sna
cd sna/samples/hello-sna
npm install
npx sna up

That'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?

FileWhat it does
.claude/skills/hello/SKILL.mdThe skill. Claude Code reads and executes this
src/App.tsxReact UI: "Say Hello" button + real-time event log
src/sna-client.tsTyped client, generated by sna gen client
.claude/settings.jsonPermission 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-builder

Then 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:

.claude/skills/hello/SKILL.md
---
description: Say hello and demonstrate the event pipeline
---
## Instructions
1. Emit a start event
2. Greet the user
3. Emit a complete event
Use the emit script for each event:
```bash
node 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:

/hello

Claude 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 updates

The 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:

.claude/skills/hello/SKILL.md
---
description: Say hello to someone
sna:
args:
name:
type: string
required: true
description: Name of the person to greet
---
## Instructions
1. Emit start
2. Greet the user by their name: {{name}}
3. Emit complete with the greeting message

Supported 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.ts

Let'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.ts

This 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

HelloButton.tsx
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

SkillStatus.tsx
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 updates

This 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 automatically
const { skills } = useSnaClient({ bindSkills });
await skills.formFill({ sessionId: 123 });
// Or use the lower-level useSna hook for manual control
const { 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 marketplace
claude mcp add sna-builder

Skill 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
PackagenpmRole
packages/core@sna-sdk/coreServer runtime, DB, CLI, event pipeline, emit script, code generation
packages/react@sna-sdk/reactSnaProvider, hooks (useSna, useSnaClient, useSkillEvents), chat UI, stores
packages/client@sna-sdk/clientTyped WebSocket client for browser and Node.js — sessions, agent, permissions
packages/testing@sna-sdk/testingsna-test CLI — isolated Claude instances with mock Anthropic API

DB Separation

DatabaseOwnerContents
data/sna.db@sna-sdk/corechat_sessions, chat_messages, skill_events
data/<app>.dbApplicationApp-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.

SDK Schema (data/sna.db)
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)
Important: Never define skill_events, chat_sessions, or chat_messages in your app's DB. All event operations go through SDK scripts or server routes.

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 UI

Why 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

TypeWhen
invokedSDK records on /agent/start (before Claude boots)
startFirst thing a skill emits
progressIncremental updates inside loops
milestoneSignificant checkpoint reached
completeSkill finished. Triggers frontend auto-refresh
errorSomething 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.

EndpointDescription
GET /healthHealth check
GET /sessionsList all sessions
POST /sessionsCreate a new session { label?, cwd?, meta? }
DELETE /sessions/:idRemove 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-onceOne-shot: spawn → wait → result → cleanup { message, model?, permissionMode? }
GET /events?since=<id>SSE stream of skill events (legacy — prefer WS)
POST /emitWrite 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-safe

useSkillEvents(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

PropTypeDescription
snaUrlstringOverride SDK server URL (auto-discovered by default)
defaultOpenbooleanOpen chat panel on mount (default: false)
dangerouslySkipPermissionsbooleanBypass Claude permission prompts (default: false)
headlessbooleanProvide context only, no built-in UI (default: false)
initialSessionIdstringSession ID to start with (default: "default")

CLI Commands

CommandDescription
sna upStart all services (DB, API server, dev server)
sna downStop all services
sna statusShow running services and ports
sna restartStop and start all services
sna initInitialize .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"}']
Warning: Always use node node_modules/@sna-sdk/core/dist/scripts/emit.js, not tsx or ts-node. The complete event triggers automatic frontend data refresh.

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", ... } }
Info: On connect, the server immediately pushes a sessions.snapshot. No subscribe needed. Snapshot is re-pushed on any session lifecycle, state, or metadata change.

Auto-Push Events (no subscribe needed)

TypePayloadWhen
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

TypeParamsResponse
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

TypeKey ParamsResponse
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

TypeParamsResponse
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

typeKey FieldsCursor
initmessage, data: { sessionId, model }persisted
thinkingmessage (extended thinking text)persisted
assistant_deltadelta (text chunk), indextransient (cursor = -1)
assistantmessage (full assistant text)persisted
tool_usemessage (tool name), data: { toolName, input, id }persisted
tool_resultmessage (truncated), data: { toolUseId, isError }persisted
user_messagemessage (user text sent via agent.send)persisted
interruptedmessage, data: { durationMs, costUsd }persisted
completemessage, data: { durationMs, costUsd, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, contextWindow, model }persisted
errormessagepersisted
Info: The cursor is a monotonic integer equal to the DB row count for this session. Only persisted events increment it. assistant_delta is transient (cursor=-1) — it is not stored in DB and carries no SSE id.

Subscribing to Agent Events

// Subscribe from cursor 0 (all history) or omit for live-only
ws.send(JSON.stringify({ type: "agent.subscribe", rid: "1", session: "default", since: 0 }));
// Receive events
ws.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/client

Basic Setup

import { SnaClient } from "@sna-sdk/client";
const sna = new SnaClient({ url: "ws://localhost:3099/ws" });
// Monitor connection
sna.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

MethodDescription
connect()Open WS connection
disconnect()Close WS connection
onConnectionStatus(cb)Connection status: 'connected'|'disconnected'|'reconnecting'

sna.sessions API

MethodDescription
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

MethodDescription
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 requests
sna.agent.onPermissionRequest(({ session, request }) => {
const { tool_name, tool_input } = request;
const approved = confirm(`Allow ${tool_name}?`);
sna.agent.respondPermission(session, approved);
});
// Other permission methods
await sna.agent.getPendingPermissions(session); // get pending request
await sna.agent.unsubscribePermissions(); // stop push
Info: After reconnect, SnaClient automatically re-subscribes to all active agent.subscribe and permission.subscribe channels. No manual re-subscription needed.

@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/testing

sna-test CLI

CommandDescription
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 lsList 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/responded
sna-test logs happy-panda --api
Info: sna-test claude passes stdio: 'inherit' so Claude Code runs interactively in your terminal — exactly like the real claude command. The only difference is ANTHROPIC_BASE_URL points to the local mock server.

Programmatic API

import { startMockAnthropicServer } from "@sna-sdk/testing";
const server = await startMockAnthropicServer();
console.log(`Mock API running at ${server.url}`);
// Listen to request/response log entries
server.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:

.claude/settings.json
{
"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:

server.ts
import { Hono } from "hono";
import { snaPortRoute } from "@sna-sdk/core/server";
const app = new Hono();
// Your app routes
app.route("/api/targets", targetsRoutes);
app.route("/api/sessions", sessionsRoutes);
// Required: SnaProvider calls this to discover the SDK server
app.get("/api/sna-port", snaPortRoute);
Warning: Do NOT create /api/events or /api/emit routes in your server. These are served by the SDK standalone server.

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:

vite.config.ts
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
},
});
Info: This Vite config is only needed when developing with link: paths to the SDK source. If you installed from npm, the default Vite config works fine.