Local apps
Apps whose code runs on your Mac — server.ts and the kernel API (tools, cards, jobs, storage, settings, secrets, prompts, agent context, host media…), why every call is awaited, and when to pick local over cloud.
A local app is a Chataway app whose code runs on the Mac, inside Chataway, instead of on a server you host. It's the kind you get when you pick Runs on my Mac in New app, and it's how most people build tools for themselves: no deployment, no domain, no review — save the file and it reloads.
Local apps never go to the store. You share them on GitHub, where they install as Unverified and run isolated.
#Local or cloud?
| Local app | Cloud app | |
|---|---|---|
| Your code runs | On the Mac, as server.ts inside Chataway | On your servers, behind an MCP endpoint |
| Access | Whatever its grants allow — up to the whole Mac (exec) | Only what Chataway hands it |
| Hosting | None | You run an https server |
| Shared through | GitHub (install link) | The Chataway store, reviewed and signed |
| Users see it as | DEV (yours) · Unverified (from GitHub) | Verified publisher |
| Good for | Your own workflow, your team, tools that need the Mac (files, local programs) | A product you ship to everyone |
Pick local when the app is for you or people who trust you, or when it genuinely needs the Mac. Pick cloud when strangers will install it — the store only takes cloud apps, and their trust model is much easier to say yes to. If the service you want already has a hosted MCP server, you may need neither: add a connector.
#The manifest
A local app uses the same plugin.json (v3) as every app, plus a server entry:
{
"pluginApiVersion": 3,
"id": "local.slack-poster",
"name": "Slack Poster",
"version": "0.1.0",
"description": "Drafts a Slack message and posts it to a webhook after you approve it.",
"icon": "📣",
"server": "server.ts",
"grants": ["network"],
"network": { "domains": ["hooks.slack.com"] },
"permissions": ["Post messages you approve to your Slack webhook (hooks.slack.com)"],
"contributes": {
"secrets": [{ "name": "SLACK_WEBHOOK_URL", "label": "Slack webhook URL", "helpUrl": "https://api.slack.com/messaging/webhooks" }],
"settings": [{ "key": "channelLabel", "label": "Channel name (for the card)", "type": "string", "default": "#general", "scope": "both" }]
}
}What's different from a store app:
server— the entry file. It's loaded with a TypeScript loader, soserver.tsworks as is; the folder needs"type": "module"in itspackage.json(the scaffold has it).- Grants — local apps may also ask for
network,execandproject:read(store apps can't). Every grant needs a sentence inpermissions. network.domains— the hosts the app talks to. Only with thenetworkgrant; hostnames,*.example.comwildcards, andlocalhost:<port>for a dev server of your own. Up to 50. When the app runs isolated, these are the only hosts it can reach; without thenetworkgrant it can't reach any.contributes.secrets— API keys the user enters in the app's settings. Your code reads them; the agent never sees them.
The ids of apps made in Chataway start with local.; that's fine — publishing a cloud app renames it to com.<publisher>.<name> for you.
#The kernel API
server.ts exports apply(ctx). Chataway calls it once for every project the app is on in, with a ctx scoped to that project and app:
export function apply(ctx: any) {
const { z } = ctx; // zod, for tool inputs
ctx.tools.register({
name: 'say_hello',
description: 'Greet someone by name. Use when the user asks the app to say hello.',
input: { name: z.string().min(1).describe('Who to greet') },
async handler({ name }: { name: string }, call: any) {
await ctx.storage.write('last.json', JSON.stringify({ name, at: Date.now() }));
await ctx.cards.create({ chatId: call.chatId, toolUseId: call.toolUseId, kind: 'notice', title: `Hello, ${name}!` });
return `Said hello to ${name}.`;
},
});
}Everything you register is undone automatically when the app reloads or is switched off — no cleanup code.
ctx. | What it's for |
|---|---|
tools.register({ name, description, input, handler }) | A tool for the agent. input is a zod shape; handler(args, call) gets call = { chatId, projectId, provider, toolUseId, signal } and returns a string or { text, isError?, images? }. A thrown error becomes an error result. |
cards.create({ chatId, toolUseId, kind, title, body?, actions?, blocking? }) | A host-drawn card: notice, gallery, proposal, form, progress or webview. With actions, await card.wait({ timeoutMs, signal }) gives the answer (or null), then card.settle({ title }). cards.onRespond(fn) catches answers that come late. See Cards & approvals. |
jobs.start({ title, chatId, run }) | Background work with a progress card: run(job) calls job.progress(0.5, 'half way'); job.signal aborts when the app is switched off. |
storage.write / read / exists / remove / ref / url / exportToProject | The app's private folder, per project. url(rel) is a signed URL a card or panel can show; exportToProject(rel, dest) copies a file into the project. |
settings.get(key) / all() / setForProject(key, value) | Values of contributes.settings (project → global → default). Chataway draws the settings UI. |
secrets.get(name) / has(name) | Values of contributes.secrets. Declared names only. |
prompts.register(text | fn, { providers? }) | Standing instructions for the agent wherever the app is on. They reach every provider — see Instructions for every agent. |
agentContext.register(turn => string | null) | Per-turn context ("the user selected clip 3"), added to the user's next message. Answer within 1.5 s. |
actions.register(name, handler) | Called from your panel with invokeAction. Only a user gesture reaches it, which makes it the door for paid or destructive work. |
project.readFile / list / writeFile | The project folder. Needs project:read / project:write. |
host.media.run(request, inputs) | Chataway's own media operations (resize, crop, convert, trim…). Needs host:media. |
files.receive(refs, { chatId }) · files.keepReturned(data, mime, name) | Files the user or agent handed over (as refs) → bytes; keep results in storage. |
accounts.token(id) / require(id, { chatId, toolUseId }) | An OAuth account the app declares, as for cloud apps. |
scope | { projectId, pluginId, projectPath, manifest, grants }. |
#await every call
Write await in front of every ctx call that returns a value — await ctx.settings.get('tone'), await ctx.secrets.has('API_KEY'), await card.settle(…). In your own app it often doesn't matter, because most calls return plain values in-process. But when the app runs isolated — always, for anyone who installs it from GitHub — your code runs in its own process and every call crosses to Chataway and back as a promise. if (!ctx.secrets.has('KEY')) then checks a promise (always truthy) and the app misbehaves only for other people.
// ✗ works in your copy, breaks for everyone who installs it
if (!ctx.secrets.has('SLACK_WEBHOOK_URL')) return 'Add the webhook in settings.';
const url = ctx.secrets.get('SLACK_WEBHOOK_URL');
// ✓ works both ways
if (!(await ctx.secrets.has('SLACK_WEBHOOK_URL'))) return 'Add the webhook in settings.';
const url = await ctx.secrets.get('SLACK_WEBHOOK_URL');The exceptions: registrations (tools.register, prompts.register…) still return their disposer synchronously, and ctx.storage.dir / resolve / exists / ref stay local. ctx.isolated is true when your code runs in its own process.
#Rules of the road
The App builder skill holds these too, so your agent follows them:
- Consequences behind a card. Anything that leaves the Mac or costs money happens only after the user taps a card or acts in your panel. Tools may propose; the user approves.
- Stay in your lane on disk. The project through
ctx.projectwith the grant declared; your own data inctx.storage. Nothing outside. - Keys are secrets, entered by the user. Never ask for a key in chat, print it or write it to a file.
- Host services before shelling out. Media work →
ctx.host.media; web pages → the managed browser;execonly when nothing else fits. - Declare what you reach. Every host in
network.domains, every grant inpermissions— that list is what people agree to when they install it.
#Next
- Isolation & permissions — what's enforced when your app runs isolated, and what
execreally means. - Sharing via GitHub — how other people install it.
- Panels & the bridge — the panel API is the same for local and cloud apps.