Intents
Enable app-to-app communication with ctx.intent() and manifest requires.
Intents
Intents let your app call tools on other installed apps without directly knowing which installation to target. Your app declares what it needs (a messaging tool, an email sender), and the Station resolves the binding at runtime — connecting your call to the right installed instance of the target app.
This is the foundation of composable agent apps: a CRM app can send emails through Gmail, a monitoring app can post alerts to Slack, and neither needs to hardcode the other's configuration.
How intents work
Your app ──ctx.intent('messenger', 'send_message', {…})──► Station
│
│ resolves binding
▼
Target app (e.g. app.slack)
│
│ calls tool
▼
Returns result ◄── back to your app- Your app declares a dependency in its manifest:
requires: [{ name: 'messenger', tools: ['send_message'] }]. - At install time, the user binds the dependency name (
messenger) to a specific installed app (e.g.app.slack). - At runtime, your app calls
ctx.intent('messenger', 'send_message', { ... }). - The Station resolves the binding, opens a session to the target app, invokes the tool, and returns the result.
- If the binding is missing or the tool call fails, an error is thrown.
Your app never references the target app's ID directly — it only knows the local dependency name and the tool name. This decoupling is what makes apps composable: the same CRM app can send email through Gmail, Outlook, or any future email app, as long as the user binds the dependency and the target app exposes the required tools.
Declare dependencies in the manifest
Add a requires array to your agentapp.json (or createApp() config). Each entry describes one app-to-app dependency:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Local dependency name. This is what you pass to ctx.intent(name, ...) in your code. |
tools | string[] | Yes | Tool names the target app must expose. At least one is required. This is a structural contract — the Station verifies the bound app provides these tools. |
suggest | string | No | Canonical app ID hint for the binding UI (e.g. 'app.slack'). Helps the user pick the right app when binding. |
optional | boolean | No | If true, the app degrades gracefully when the dependency is unbound. Default: false. |
{
"id": "app.example.crm",
"version": "1.0.0",
"name": "CRM",
"requires": [
{
"name": "email",
"tools": ["send_email"],
"suggest": "app.google.gmail",
"optional": true
},
{
"name": "messenger",
"tools": ["send_message"],
"suggest": "app.slack"
}
]
}In this example:
- The CRM app requires an
emaildependency that must expose asend_emailtool. It's optional, so the app works without it. - The CRM app requires a
messengerdependency that must expose asend_messagetool. It's required — the app cannot function without it.
Using createApp()
When using the SDK's createApp() helper, pass requires in the config object:
import { createApp } from '@agentapps/sdk';
const app = createApp({
id: 'app.example.crm',
version: '1.0.0',
name: 'CRM',
description: 'A CRM that sends emails and Slack messages via other apps',
author: { name: 'Example', email: 'dev@example.com' },
category: 'productivity',
runtime: { transport: 'stdio', command: 'node', args: ['./dist/index.js'] },
requires: [
{
name: 'email',
tools: ['send_email'],
suggest: 'app.google.gmail',
optional: true,
},
{
name: 'messenger',
tools: ['send_message'],
suggest: 'app.slack',
},
],
});Call intents from your tools
Use ctx.intent(binding, tool, args) inside any tool handler to invoke a tool on a bound app:
app.tool('notify_deal_closed', {
description: 'Send a notification when a deal is closed',
parameters: {
type: 'object',
properties: {
dealName: { type: 'string' },
amount: { type: 'number' },
channel: { type: 'string', description: 'Slack channel to notify' },
},
required: ['dealName', 'amount', 'channel'],
},
}, async (ctx, args) => {
// Call the bound messenger app's send_message tool
const result = await ctx.intent('messenger', 'send_message', {
channel: args.channel,
text: `🎉 Deal closed: ${args.dealName} ($${args.amount.toLocaleString()})`,
});
return {
text: `Notification sent to ${args.channel}`,
data: result,
};
});Parameters
| Parameter | Type | Description |
|---|---|---|
binding | string | The local dependency name from your requires manifest (e.g. 'messenger', 'email'). |
tool | string | The tool name within the bound target app (e.g. 'send_message', 'send_email'). |
args | Record<string, unknown> | Optional. Arguments to pass to the target tool. |
Return value
ctx.intent() returns Promise<unknown> — the result from the target tool. The shape depends on the target app's tool definition.
Error handling
ctx.intent() throws if the binding is missing, the target app is not installed, or the tool call fails. Always wrap intent calls in try/catch:
app.tool('send_followup_email', {
description: 'Send a follow-up email to a lead',
parameters: {
type: 'object',
properties: {
to: { type: 'string' },
subject: { type: 'string' },
body: { type: 'string' },
},
required: ['to', 'subject', 'body'],
},
}, async (ctx, args) => {
try {
const result = await ctx.intent('email', 'send_email', {
to: args.to,
subject: args.subject,
body: args.body,
});
return { text: 'Email sent successfully', data: result };
} catch (err) {
// The error may include a `code` and `details` from the target app
const code = (err as Error & { code?: string }).code;
const details = (err as Error & { details?: unknown }).details;
if (code === 'BINDING_NOT_FOUND') {
return { text: 'No email app is bound. Please bind an email app in settings.' };
}
return {
text: `Failed to send email: ${(err as Error).message}`,
data: { code, details },
};
}
});Common error codes
| Code | Meaning |
|---|---|
BINDING_NOT_FOUND | No app is bound to the dependency name. The user needs to bind an app in the Station's binding UI. |
TOOL_NOT_FOUND | The bound app does not expose the requested tool. Check that the tools array in your requires manifest matches the target app's tool names. |
TOOL_EXECUTION_FAILED | The target app's tool threw an error. The details field may contain more context from the target app. |
Optional dependencies
When a dependency is marked optional: true in the manifest, your app should handle the case where no app is bound. Use try/catch and check for BINDING_NOT_FOUND:
app.tool('create_contact', {
description: 'Create a new contact and optionally send a welcome email',
parameters: {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string' },
},
required: ['name', 'email'],
},
}, async (ctx, args) => {
// Save the contact (core functionality — always works)
const contact = await ctx.storage.set(`contact:${args.email}`, {
name: args.name,
email: args.email,
createdAt: new Date().toISOString(),
});
// Try to send a welcome email (optional — degrades gracefully)
try {
await ctx.intent('email', 'send_email', {
to: args.email,
subject: `Welcome, ${args.name}!`,
body: `Hi ${args.name}, thanks for joining!`,
});
return { text: `Contact created and welcome email sent to ${args.email}` };
} catch (err) {
// No email app bound — that's fine, the contact is still saved
return { text: `Contact created. (Welcome email skipped — no email app bound.)` };
}
});Bindings: how the Station resolves dependencies
Bindings are the user's responsibility. When a user installs an app that declares requires, the Station presents a binding UI where the user selects which installed app satisfies each dependency.
Installing app.example.crm v1.0.0
├── Requires: email (send_email) → Bind to: app.google.gmail ✓
├── Requires: messenger (send_message) → Bind to: app.slack ✓
└── Optional: (none)
Ready to installThe Station validates that the selected app exposes all the tools listed in the tools array. If the bound app doesn't expose a required tool, the binding is rejected.
Multiple installations
If a user has multiple installations of the same app (e.g. two Gmail accounts), the binding lets them choose which installation to use. Your app doesn't need to know — it just calls ctx.intent('email', 'send_email', ...) and the Station routes it to the bound installation.
Rebinding
Bindings can be changed after installation. If a user uninstalls the bound app, the Station marks the binding as broken and the user must rebind. Your app will receive a BINDING_NOT_FOUND error until a new binding is set.
Use cases
CRM → Email
A CRM app tracks deals and contacts. When a deal closes, it sends a follow-up email through the user's installed email app:
app.tool('close_deal', {
description: 'Mark a deal as closed and send a notification email',
parameters: {
type: 'object',
properties: {
dealId: { type: 'string' },
customerEmail: { type: 'string' },
},
required: ['dealId', 'customerEmail'],
},
}, async (ctx, args) => {
// Update deal status in storage
await ctx.storage.set(`deal:${args.dealId}`, {
status: 'closed',
closedAt: new Date().toISOString(),
});
// Send email via bound email app
await ctx.intent('email', 'send_email', {
to: args.customerEmail,
subject: 'Deal closed — thank you!',
body: 'Your deal has been successfully processed.',
});
return { text: `Deal ${args.dealId} closed and email sent.` };
});Monitoring → Slack
A monitoring app detects issues and posts alerts to Slack via a bound messenger app:
app.tool('check_service_health', {
description: 'Check service health and alert on failures',
parameters: {
type: 'object',
properties: {
serviceUrl: { type: 'string' },
alertChannel: { type: 'string', description: 'Slack channel for alerts' },
},
required: ['serviceUrl', 'alertChannel'],
},
}, async (ctx, args) => {
const response = await fetch(args.serviceUrl);
const healthy = response.ok;
if (!healthy) {
// Alert the team via bound messenger app
await ctx.intent('messenger', 'send_message', {
channel: args.alertChannel,
text: `🚨 Service down: ${args.serviceUrl} returned ${response.status}`,
});
}
return {
text: healthy ? 'Service is healthy' : `Service is down — alert sent to ${args.alertChannel}`,
data: { status: response.status, healthy },
};
});Project Manager → Calendar + Email
A project management app uses two dependencies — a calendar app for scheduling and an email app for notifications:
{
"requires": [
{
"name": "calendar",
"tools": ["create_event", "list_events"],
"suggest": "app.google.calendar"
},
{
"name": "email",
"tools": ["send_email"],
"suggest": "app.google.gmail",
"optional": true
}
]
}app.tool('schedule_meeting', {
description: 'Schedule a meeting and email attendees',
parameters: {
type: 'object',
properties: {
title: { type: 'string' },
date: { type: 'string' },
attendees: { type: 'array', items: { type: 'string' } },
},
required: ['title', 'date', 'attendees'],
},
}, async (ctx, args) => {
// Create calendar event via bound calendar app
const event = await ctx.intent('calendar', 'create_event', {
title: args.title,
date: args.date,
attendees: args.attendees,
});
// Optionally email attendees
try {
await ctx.intent('email', 'send_email', {
to: args.attendees.join(', '),
subject: `Meeting scheduled: ${args.title}`,
body: `You've been invited to a meeting on ${args.date}.`,
});
} catch {
// Email is optional — calendar event was still created
}
return { text: `Meeting "${args.title}" scheduled for ${args.date}.`, data: event };
});Best practices
- Use descriptive dependency names.
messenger,email,calendarare clear. Avoid generic names likeapp1orservice. - Declare only the tools you use. The
toolsarray is a contract — listing tools you don't call adds unnecessary constraints on the bound app. - Mark truly optional dependencies as
optional: true. This tells the Station and the user that your app works without them. - Always handle
BINDING_NOT_FOUNDfor optional dependencies. For required dependencies, a clear error message is fine — the user must bind before the app can work. - Use
suggestto guide binding. If your app is designed to work with a specific app (e.g. Slack for messaging), setsuggestto that app's ID. The user can still bind a different app, but the hint improves the UX.