Creating Apps

Complete guide to building apps with actions, webhooks, OAuth, and the full tools API

Apps are TypeScript modules that integrate external services into your workspace. They can define actions callable from chat and flows, receive webhooks, sync data on a schedule, authenticate via OAuth, and render UI. This guide covers the full createApp API and every tool available to your app code.

App structure

Every app is a single file that default-exports a createApp() call:

import { createApp } from 'start/app'
export default createApp({
id: 'my-app',
name: 'My App',
description: 'What the app does',
version: '0.0.1',
icon: './icon.svg',
theme: 'blue',
actions: {
query: {
description: 'the generic query action',
async run({ input, tools, context }) {
return `you said: ${input.instruction}`
},
},
},
})

Users invoke actions from chat (/my-app do something) or from flows as pipeline steps.

App definition

The createApp function accepts an AppDefinition object:

FieldTypeDescription
idstringunique identifier, used in flow steps and URLs
namestringdisplay name
descriptionstringshown in the marketplace and to the AI
versionstringsemver string
iconstringLucide icon name or path to an image file
themestringcolor theme for UI accents (blue, green, orange, etc.)
optionsobjectconfigurable options schema (see below)
optionsDefaultsobjectdefault values for options
optionsDescriptionsobjecthelp text for each option
optionsRequiredobjectwhich options are required
oauthobjectOAuth configuration
perUserbooleanwhether the app stores data per-user
eventsobjectdeclarative webhook events the app emits
outputsobjectdescribes the shape of data the app produces
apiobjectAPI configuration for strategy-driven actions
helpersobjectpure functions available in strategy-generated code
actionsobjectnamed actions the app exposes
onWebhookfunctionhandler for incoming webhook payloads
onAuthSuccessfunctionruns after successful OAuth
suggestedFlowsAndBlocksarraysuggested flow/block threads created on install
frontendfunctionReact component for the app UI

Options

Options let server admins configure your app at install time. Define them as a schema of 'string' | 'number' | 'secret' fields:

export default createApp({
id: 'my-app',
// ...
options: {
repos: 'string',
interval: 'number',
apiKey: 'secret',
},
optionsDefaults: {
interval: 30,
},
optionsDescriptions: {
repos: 'Comma-separated list of owner/repo to sync',
interval: 'Sync interval in minutes',
},
optionsRequired: {
repos: true,
},
})

Secret options are stored server-side only and never synced to clients. They render as a password field in the UI. At runtime, secret values are resolved and merged into options just like string and number fields.

Options are available in every action and backend run via options:

async run({ options, tools }) {
const repos = options.repos.split(',')
}

API configuration

If your app talks to an external API, the api field tells the platform how to make requests on your behalf. This is especially important for strategy-driven actions (see below), where the platform uses your API docs to generate the right calls automatically.

export default createApp({
id: 'my-app',
// ...
api: {
base: 'https://api.example.com/v1',
auth: 'bearer',
docs: ` GET /items - list all items GET /items/:id - get a single item POST /items - create an item (body: { name, description }) `,
},
})
FieldTypeDescription
basestringbase URL for the external service API. supports {{option}} template vars that resolve from your app’s configured options
authstringauthentication type: 'bearer' (Authorization header), 'basic' (Basic auth), or 'custom' (you handle it in headers)
docsstringnatural language API documentation that guides how the app interacts with the service

The docs field is the most important part — it tells the platform what endpoints exist, what parameters they accept, and what they return. Write it in plain English or as a concise endpoint reference. The better your docs, the more reliably strategy-driven actions will work.

Helpers

Pure functions you want available inside strategy-generated code. Useful for date formatting, unit conversion, data transformation, or any reusable logic that doesn’t make API calls:

export default createApp({
id: 'my-app',
// ...
helpers: {
formatCurrency: '(cents) => `$${(cents / 100).toFixed(2)}`',
toUnixTs: '(dateStr) => Math.floor(new Date(dateStr).getTime() / 1000)',
},
})

Each helper is a string containing a JS function expression. Helpers are injected as top-level variables in generated snippets alongside fetch, store, stat, and other built-ins. Keep them pure — no API calls, no side effects, just transforms.

Actions

Actions are the primary way apps expose functionality. They can be invoked from chat, flows, and the AI agent. Each action has a description (used by the AI to decide when to call it) and optionally a run function.

actions: {
sync: {
description: 'Sync issues from the configured repos',
noun: 'issues',
messageFormat: '[repo]',
requiresInput: true,
async run({ input, context, options, tools }) {
// ...
return { summary: 'synced 42 issues', count: 42 }
},
},
}

Action properties

FieldTypeDescription
descriptionstringwhat the action does (shown to AI and users)
nounstringwhat the action returns (e.g. “issues”, “response”)
messageFormatstringdescribes expected input shape
requiresInputbooleanwhether the action needs input to run
synonymsstring[]alternate terms that help match user instructions
runsOn'server' | 'desktop'where the action executes
runfunctionthe action implementation (omit for strategy-driven)

When runsOn is 'desktop', the action executes on the user’s machine via the desktop app instead of in the cloud sandbox. This unlocks tools.files for local filesystem access, which is useful for scanning directories, exporting data to local files, or reading local configuration. Desktop actions can’t be triggered by scheduled flows — they require an active desktop session.

Strategy-driven vs imperative actions

Actions come in two flavors:

  • Strategy-driven — omit the run() function. The platform uses AI to generate code that calls your app’s API based on the api.docs, outputs, and the user’s instruction. The generated code is cached after the first run, so subsequent calls are fast and deterministic. This is the preferred approach for flexible API interactions where the user might ask for anything.

  • Imperative — provide a run() function with your own logic. Use this for deterministic operations like syncing data, managing webhook subscriptions, or anything where you need precise control over the behavior.

actions: {
// strategy-driven: AI figures out how to query the API
query: {
description: 'look up information from the service',
},
// imperative: you write the exact logic
sync: {
description: 'sync all data from the configured repos',
async run({ options, tools }) {
const data = await fetch(`https://api.example.com/repos/${options.repo}`)
// ...
},
},
}

Synonyms

Synonyms are alternate terms that help the platform route user instructions to the right action. They’re matched against the user’s input to find the best action to run.

actions: {
payments: {
description: 'look up payment and revenue data',
synonyms: ['revenue', 'transactions', 'charges', 'sales'],
},
}

If not provided, synonyms are auto-generated from the action name and description at build time. Set synonyms: [] to explicitly opt out of fuzzy matching — useful for catch-all actions like query that should only match when no other action does.

Run context

The run function receives:

PropertyDescription
inputthe user’s input (see below)
contextchannel/server context
optionsconfigured app options
toolsthe full tools API
controllerAbortController for cancellation
oauthTokenOAuth token if the app uses OAuth

Input object

The input argument contains the user’s input parsed into several forms:

type AppActionInput = {
instruction: string // NLP text after /app action — the user's instruction
summary: string // previous step's summary output
data: AppResultDataItem[] // structured pipeline data from previous steps
}

In a flow pipeline like github.sync tamagui/tamagui | notify.default, the notify step receives the output of github.sync as input.summary.

Action context

type AppActionContext = {
serverId: string
channelId?: string
threadId?: string
object: Record<string, unknown> // accumulated cross-step context
}

Return values

Actions can return a string, an AppResult object, or void:

// simple string
return 'done'
// structured result with data items
return {
summary: '25 payments totaling $3,420',
data: [
{
type: 'payment',
key: 'stripe-payment:ch_abc',
value: { amount: 420, email: 'alice@co.com', status: 'succeeded' },
title: 'alice@co.com - $420',
status: 'succeeded',
url: 'https://dashboard.stripe.com/payments/ch_abc',
},
],
revenue: 3420, // extra fields passed to next pipeline step
}

The summary field is the human-readable string displayed to users and piped to downstream steps. The data array contains structured AppResultDataItem objects with well-defined fields the UI can render automatically — titles as text, statuses as badges, urls as links, kinds for grouping. Any extra fields on the returned object are merged into the pipeline’s context object. The data array flows forward as the next step’s input.data.

outputs are for deferred chat-side effects the runtime can execute after the action returns, such as posting messages, reacting, or pinning. Notifications and other UI feedback should be done imperatively from app code with tools like tools.notify, not returned through outputs.

Data items have these fields:

FieldTypeDescription
typestringitem type (e.g. “payment”, “issue”)
keystringunique key for deduplication
valueanydomain-specific payload
titlestringhuman-readable title
statusstringstatus string (rendered as badge)
kindstringkind for grouping/filtering
urlstringexternal link (rendered as clickable)
tagsstring[]tags for filtering
externalIdstringID in the external system

Returning data items does not automatically persist them to the data store — use tools.data.public.set() for that. Data items are the structured “view” of what the action produced, consumed by the pipeline, blocks, and the UI.

The query action

An action named query is the generic ask-anything entrypoint for an app. It is used for ad-hoc natural-language requests and runs when the app is called without a more specific action name:

actions: {
query: {
description: 'generic natural-language entrypoint for this app',
async run({ input, tools }) {
return input.instruction
},
},
}

Tools API

Every action, backend, and webhook handler receives a tools object with the full platform API.

tools.data

Store and retrieve structured data. Both tools.data.public and tools.data.private share the same interface.

  • public data is visible to all server members and searchable by the AI
  • private data is scoped to the app install (sync cursors, tokens, state)
// set a value with metadata
await tools.data.public.set('customer:123', JSON.stringify(customer), {
title: customer.name,
kind: 'customer',
status: 'active',
tags: ['enterprise'],
url: 'https://example.com/customer/123',
})
// get a single value
const item = await tools.data.public.get('customer:123')
// returns: { key, value, title, kind, status, tags, ... }
// query with filters
const active = await tools.data.public.getAll({
kind: 'customer',
status: 'active',
sort: 'newest',
limit: 50,
})
// delete
await tools.data.public.delete('customer:123')

set(key, value, meta?) stores a key/value pair. The value should be a string (JSON-stringify objects). The optional meta object supports:

FieldTypeDescription
titlestringhuman-readable title
kindstringdata kind for filtering
statusstringstatus indicator
tagsstring[]tags for filtering
weightnumbersort weight
formatstring'text' | 'json' | 'markdown'
urlstringexternal URL
externalIdstringID in the external system
startsAtnumberstart timestamp
endsAtnumberend timestamp
createdAtnumbercreation timestamp
updatedAtnumberlast update timestamp
syncedAtnumberlast sync timestamp
aiVisibilitystring'context' | 'searchable' | 'hidden'
scopestring'user' | 'server' | 'channel'
extraobjectarbitrary extra metadata

get(key) returns a Datum object { key, value, title, kind, ... } or null.

getAll(query?) returns an array of all data entries. The optional query object filters results:

FieldTypeDescription
kindstringfilter by kind
statusstringfilter by status
tagsstring[]filter by tags
limitnumbermax results
sortstring'newest' | 'oldest'

delete(key) removes an entry by key.

stat(key, value, meta?) stores a point-in-time stat (counts, gauges, current values). Handy for dashboard blocks:

await tools.data.public.stat('daily-summary', {
openIssues: 12,
closedToday: 3,
})

series(name, value, options?) stores a time-series data point with auto-aggregation. Deduplicates within the period and computes running aggregates (avg, sum, min, max, count):

await tools.data.public.series(
'response-time',
{ value: 230 },
{
period: 'hourly', // 'hourly' | 'daily' | 'weekly' | 'monthly'
},
)

tools.message

Post messages to channels:

await tools.message.insert({
content: 'deployment succeeded',
channelId: context.channelId,
serverId: context.serverId,
threadId: context.threadId, // optional, replies in a thread
})

React to a message with an emoji:

await tools.message.react({
messageId: 'msg_abc',
emoji: 'thumbsup', // emoji keyword or unicode value
})

Pin or unpin a message (toggles):

const result = await tools.message.pin({
messageId: 'msg_abc',
channelId: context.channelId,
serverId: context.serverId,
})
// result: { pinned: true } or { pinned: false }

tools.notify

Imperatively create notifications in the workspace. This is the correct path for durable alerts; do not return notifications through outputs:

// in-app notification (info by default)
tools.notify('sync completed')
tools.notify('rate limit hit', 'warn')
tools.notify('sync failed', 'error')

Send a push notification to a specific user:

// by user ID or @username
await tools.notify.push('user-id-or-@username', 'you have a new assignment')
// with title and body
await tools.notify.push('@alice', {
title: 'Deploy Complete',
body: 'v2.1.0 is live on production',
})

tools.generate

Generate text or structured data with AI. Always calls the model — never cached. Use for creative or dynamic tasks where caching doesn’t make sense.

// generate text
const summary = await tools.generate.text('md', `Summarize: ${text}`)
// generate structured JSON
const entities = await tools.generate.object(
'sm',
`Extract people and places from: ${text}`,
{ people: ['string'], places: ['string'] },
)

Model tiers: 'xxs', 'xs', 'sm', 'md', 'lg', 'xl'. xxs is the cheapest option, good for simple extraction and classification tasks. xl uses the most capable models for complex reasoning. You can also pass a specific model ID string.

tools.abort

Stop the current flow pipeline. Throws immediately and skips remaining steps:

const data = await tools.data.private.get('api-key')
if (!data) {
tools.abort('no API key configured')
}

tools.progress

Update a progress indicator visible in the app UI (0-100):

tools.progress(0)
for (let i = 0; i < items.length; i++) {
await processItem(items[i])
tools.progress(Math.round(((i + 1) / items.length) * 100))
}

tools.webhooks

Webhook subscriptions are managed through flow event triggers. When a flow uses on app.event, the platform automatically registers and manages the webhook with the external service. See When Flows Run for details on event triggers.

Apps declare the events they emit in their events definition, and flows listen with on app.event syntax. The onWebhook handler on the app processes incoming payloads and returns a matching event name.

tools.flows

Programmatically create and delete flows:

// create a flow from pipedown syntax
const { id } = await tools.flows.create(` every 1 hour /github sync /notify push sync complete `)
// delete a flow (only flows created by this app)
await tools.flows.delete(id)

tools.helpers

const slug = tools.helpers.slugify('Hello World') // 'hello-world'
const id = tools.helpers.randomId() // random unique string

tools.crypto

Sign JWTs and encode data:

// sign a JWT (RS256)
const token = await tools.crypto.signJWT(
{ iss: 'my-app', sub: 'user-123' },
privateKeyPEM,
{ expiresIn: '10m' },
)
// base64url encode
const encoded = tools.crypto.base64url('hello')

tools.strategy

Learn on first run, execute deterministically after. On the first run, the strategy system uses AI to generate a JS snippet that maps input to output. On subsequent runs, the cached snippet executes instantly — no AI call, same result. Self-healing: if the cached code fails, it regenerates with error context.

Three methods cover all use cases:

// extract a structured object from input
const params = await tools.strategy.object({
task: 'parse stripe query into a relative period type',
schema: { period: '"today"|"week"|"month"', n: '?number' },
})
// → { period: "week" }
// extract an array of items
const steps = await tools.strategy.list({
task: 'break deployment plan into ordered steps',
schema: { name: 'string', command: 'string' },
})
// → [{ name: "build", command: "npm run build" }, ...]
// map raw API data to standardized data items
const result = await tools.strategy.data({
data: rawStripeResponse,
task: 'stripe payments this week',
fields: ['amount', 'customer_email', 'status'],
})
// → { summary: "Found 25 payments...", items: AppResultDataItem[], count: 25 }
return { summary: result.summary, data: result.items }

Strategies must be time-independent — cache the shape of an operation (relative period types, field names), not concrete timestamps. Resolve time-dependent values at runtime in your action code.

// BAD — timestamps get cached, stale on next run
const parsed = await tools.strategy.object({
task: `Parse: "today"\nCurrent time: ${new Date().toISOString()}`,
schema: { created: { gte: 'unix_ts', lte: 'unix_ts' } },
})
// GOOD — relative type is cached, resolved at runtime
const parsed = await tools.strategy.object({
task: `Parse: "today" into a relative period type`,
schema: { relative: '"today"|"yesterday"|"last-n-days"', n: '?number' },
})
const params = resolveAtRuntime(parsed, new Date())

See the tools reference for the full strategy API.

tools.files (desktop only)

File system access scoped to ~/.start/{serverSlug}/apps/{appSlug}/files/. Only available in actions with runsOn: 'desktop'.

await tools.files.write('config.json', JSON.stringify(config))
await tools.files.writeJSON('config.json', { key: 'value' })
const content = await tools.files.read('config.json')
const data = await tools.files.readJSON('config.json')
const exists = await tools.files.exists('config.json')
const files = await tools.files.list('/', {
recursive: true,
pattern: '*.json',
})
await tools.files.delete('config.json')
const abs = tools.files.resolve('config.json')

OAuth

Connect your app to third-party services with OAuth. Declare the provider and scopes:

export default createApp({
id: 'my-github-app',
// ...
oauth: {
provider: 'github', // 'github' | 'stripe' | 'linear' | 'google'
scope: ['user:email', 'public_repo'],
},
async onAuthSuccess({ tools, oauthToken }) {
// runs once after successful OAuth — good for one-time setup
// like storing initial config or validating the connection
if (!oauthToken) return
const res = await fetch('https://api.example.com/me', {
headers: { Authorization: `Bearer ${oauthToken}` },
})
const user = await res.json()
await tools.data.private.set('account', JSON.stringify(user))
},
})

After OAuth, the token is available as oauthToken in every run context. Destructure it directly — there’s no need to fetch it from the data store:

async run({ oauthToken, tools }) {
const res = await fetch('https://api.example.com/me', {
headers: { Authorization: `Bearer ${oauthToken}` },
})
}

Webhooks

Apps can receive webhooks from external services. Declare the events your app emits, then handle incoming payloads:

export default createApp({
id: 'my-app',
// ...
events: {
deploy: {
description: 'deployment completed',
payload: {
status: 'success | failure',
environment: 'string',
url: '?string',
},
entity: 'deployment',
},
},
async onWebhook({ context, payload, tools, options }) {
const { headers, ...body } = payload
await tools.message.insert({
content: `deploy to ${body.environment}: ${body.status}`,
channelId: context.channelId,
serverId: context.serverId,
})
return {
event: 'deploy', // matches a key in events
summary: 'deployment received', // displayed as message
data: [
{
type: 'deployment',
key: `deploy:${body.environment}`,
value: body,
title: `${body.environment} deploy`,
status: body.status,
},
],
}
},
})

Events declared in events can be used as flow triggers. For example, a flow with on my-app.deploy triggers whenever the webhook fires with event: 'deploy'.

Outputs

Describe the shape of data your app produces. Each key names a data type and maps to a simple schema of its fields. This helps the flow runner and AI understand what your actions return, and for strategy-driven actions, it guides the generated code to produce correctly shaped results.

outputs: {
issue: {
title: 'string',
number: 'number',
state: 'string',
assignee: '?string',
},
deployment: {
url: 'string',
status: 'string',
environment: 'string',
},
},

Types use a simple JSON schema: 'string', 'number', 'boolean', or prefix with ? for optional fields.

Suggested Flows And Blocks

Create suggested automation threads when the app is installed:

suggestedFlowsAndBlocks: [
{
explanation: 'Keeps issue data fresh every hour.',
flow: ` # Sync Issues Sync issues into app data every hour. every hour /github sync `.trim(),
},
],

You can also suggest blocks in their own threads:

suggestedFlowsAndBlocks: [
{
explanation: 'Shows open issue count in a dedicated thread.',
block:
'<Number query="app:github category:issue state:open" extract="value" label="Open Issues" />',
},
],

Frontend

Apps can render a full UI panel via the frontend function. It receives the app’s configured options and the tools object:

export default createApp({
id: 'my-app',
// ...
frontend({ options, tools }) {
return (
<YStack p="$4" gap="$2">
<SizableText>Configured repos: {options.repos}</SizableText>
</YStack>
)
},
})

The frontend function signature:

frontend(props: { options?: ParsedOptions; tools: AppTools }): ReactNode

Use options to display current configuration and tools for data access (tools.data.public.getAll(), etc.). The frontend renders in the apps panel and can display alongside your blocks.

Sandbox environment

Backends and actions run in a sandboxed isolate with these globals available: fetch, setTimeout, clearTimeout, URL, URLSearchParams, console, Buffer, TextEncoder, TextDecoder, btoa, atob, and the Web Crypto API. No direct filesystem or network access beyond fetch. Memory is capped at 128 MB and execution times out after 60 seconds.

Complete example

An app that monitors endpoint health, stores results, and notifies on failures:

import { createApp } from 'start/app'
export default createApp({
id: 'uptime',
name: 'Uptime',
description: 'Monitor endpoint health and alert on downtime',
version: '0.0.1',
icon: 'Activity',
theme: 'green',
options: {
urls: 'string',
},
optionsDescriptions: {
urls: 'Comma-separated URLs to monitor',
},
optionsRequired: {
urls: true,
},
events: {
down: {
description: 'an endpoint went down',
payload: { url: 'string', status: 'number', error: '?string' },
},
},
outputs: {
health: {
url: 'string',
status: 'number',
latency: 'number',
ok: 'boolean',
},
},
actions: {
check: {
description: 'Check health of all configured endpoints',
noun: 'health report',
async run({ options, tools, context }) {
const urls = options.urls.split(',').map((u) => u.trim())
const results = []
for (const url of urls) {
const start = Date.now()
try {
const res = await fetch(url, { method: 'HEAD' })
const latency = Date.now() - start
results.push({ url, status: res.status, latency, ok: res.ok })
await tools.data.public.set(
`health:${url}`,
JSON.stringify({
url,
status: res.status,
latency,
ok: res.ok,
}),
{
title: url,
kind: 'health',
status: res.ok ? 'up' : 'down',
},
)
if (!res.ok) {
await tools.notify.push('@admin', {
title: 'Endpoint Down',
body: `${url} returned ${res.status}`,
})
}
} catch (err) {
results.push({
url,
status: 0,
latency: 0,
ok: false,
error: err.message,
})
await tools.data.public.set(
`health:${url}`,
JSON.stringify({
url,
status: 0,
latency: 0,
ok: false,
}),
{
title: url,
kind: 'health',
status: 'down',
},
)
}
}
const down = results.filter((r) => !r.ok)
return {
summary: down.length
? `${down.length} endpoint(s) down: ${down.map((d) => d.url).join(', ')}`
: `all ${results.length} endpoints healthy`,
data: results.map((r) => ({
type: 'health',
key: `health:${r.url}`,
value: r,
title: r.url,
status: r.ok ? 'up' : 'down',
})),
}
},
},
},
suggestedFlowsAndBlocks: [
{
explanation: 'Runs health checks every 5 minutes.',
flow: ` # Health Check Check configured endpoints every 5 minutes. every 5 minutes /uptime check `.trim(),
},
],
})

Publishing to the marketplace

Once your app is ready, you can publish it to npm and submit it for listing in the start.chat marketplace.

1. Scaffold and build

Terminal

start app init my-app # create the project
cd my-app
# edit app.tsx...
start app build # compile to .app.json

2. Publish to npm

Terminal

start app publish # build + publish to npm + register

This builds the app, publishes to npm, and registers it with start.chat. Use --dry-run to validate without publishing.

3. Submit for marketplace listing

Terminal

start app submit my-app # submit for review

Once submitted, the app enters a review queue. Approved apps appear in the marketplace with a verified badge. See the CLI reference for app install and app submit for full command details.