Chataway Developers
Concepts

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 appCloud app
Your code runsOn the Mac, as server.ts inside ChatawayOn your servers, behind an MCP endpoint
AccessWhatever its grants allow — up to the whole Mac (exec)Only what Chataway hands it
HostingNoneYou run an https server
Shared throughGitHub (install link)The Chataway store, reviewed and signed
Users see it asDEV (yours) · Unverified (from GitHub)Verified publisher
Good forYour 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:

plugin.json
json
{
  "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, so server.ts works as is; the folder needs "type": "module" in its package.json (the scaffold has it).
  • Grants — local apps may also ask for network, exec and project:read (store apps can't). Every grant needs a sentence in permissions.
  • network.domains — the hosts the app talks to. Only with the network grant; hostnames, *.example.com wildcards, and localhost:<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 the network grant 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:

server.ts
ts
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 / exportToProjectThe 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 / writeFileThe 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.

ts
// ✗ 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:

  1. 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.
  2. Stay in your lane on disk. The project through ctx.project with the grant declared; your own data in ctx.storage. Nothing outside.
  3. Keys are secrets, entered by the user. Never ask for a key in chat, print it or write it to a file.
  4. Host services before shelling out. Media work → ctx.host.media; web pages → the managed browser; exec only when nothing else fits.
  5. Declare what you reach. Every host in network.domains, every grant in permissions — that list is what people agree to when they install it.

#Next