iMessage custom toolkit with eve
Most Composio toolkits call a remote API. Some capabilities only exist on one machine, and iMessage is the classic case: there's no iMessage cloud API, so the agent has to run on your Mac and drive Messages.app directly.
This example builds exactly that: a terminal agent that texts on your behalf from your own Mac and reaches the rest of your apps (Gmail, Calendar, GitHub, Slack) through the Composio catalog in the same breath.
you › read my last email and text Shams a summary
you › text mom i'm running 10 min late
you › what did Lena and I last text about?The pattern is the takeaway, not iMessage specifically. It comes together from a handful of Composio pieces:
- A custom toolkit wraps local iMessage (send, contacts, history, memory) as Composio tools.
- In-process execution runs each tool right on your Mac through
session.execute, with no remote API. - One session puts those local tools on the same surface as the 1000+ app catalog.
- The eve provider turns
session.tools()into eve-native tools, soeve devcan call them. - Triggers let an outside event wake the agent and reach your phone, with in-chat auth through
COMPOSIO_MANAGE_CONNECTIONSfor any app you haven't connected.
you eve
Picks the tool it needs and runs it through the session.
- iMessageon your Maclocal
- Gmailcatalogremote
- GitHubcatalogremote
Local tools run in-process on your Mac; the rest of the catalog runs remote, all from one session.
Below you build the integration core: the custom toolkit first, then the wiring that puts it on a session, then triggers, then a browse of the relevant source. You bring a Composio API key and a Mac. Composio brings the catalog.
Setup
You need a Composio API key and macOS (the iMessage tools drive Messages.app, Contacts.app, and the local chat.db).
Install
npm install @composio/core @composio/experimental eveConfigure
COMPOSIO_API_KEY=xxxxxxxxxGrant macOS permissions (prompted on first use): Automation for Messages, Contacts access, and Full Disk Access to read chat.db.
The custom toolkit
A custom toolkit is a named group of custom tools. Each tool declares an input schema and an execute that runs locally. Here is SEND, which shells out to AppleScript to drive Messages.app:
A custom tool is a slug, a description, and an execute. preload:true surfaces it in session.tools() without a search step.
12345678910import { experimental_createTool } from '@composio/core';import { z } from 'zod/v3';
export const sendMessage = experimental_createTool('SEND', { name: 'Send iMessage', description: 'Send an iMessage from your Mac to a phone number or iMessage email.', preload: true, inputParams: z.object({}), execute: async () => ({ sent: false }),});Declare the input schema with zod/v3, which the Composio custom-tool API expects.
3 unmodified lines4567889101112133 unmodified linesexport const sendMessage = experimental_createTool('SEND', { name: 'Send iMessage', description: 'Send an iMessage from your Mac to a phone number or iMessage email.', preload: true, inputParams: z.object({}), inputParams: z.object({ to: z.string().describe('Phone number or iMessage email.'), text: z.string().describe('Message body to send.'), }), execute: async () => ({ sent: false }),});The execute runs locally on your Mac, driving Messages.app through osascript.
12345671 unmodified line9101112121314151617import { experimental_createTool } from '@composio/core';import { z } from 'zod/v3';import { runAppleScript, SEND_SCRIPT } from './applescript';
export const sendMessage = experimental_createTool('SEND', { name: 'Send iMessage', description: 'Send an iMessage from your Mac to a phone number or iMessage email.',1 unmodified line inputParams: z.object({ to: z.string().describe('Phone number or iMessage email.'), text: z.string().describe('Message body to send.'), }), execute: async () => ({ sent: false }), execute: async ({ to, text }) => { await runAppleScript(SEND_SCRIPT, [to, text]); return { sent: true, to }; },});Group your tools into a toolkit. The full project also ships FIND_CONTACT (fuzzy contact lookup over Contacts.app), READ_MESSAGES (recent messages from chat.db), and RECALL/REMEMBER (per-contact memory), each built the same way:
import { experimental_createToolkit } from '@composio/core';
import { sendMessage } from './send-message';
import { findContact } from './find-contact';
import { readMessages } from './read-messages';
import { recallContact, rememberContact } from './memory-tools';
export function createImessageToolkit() {
return experimental_createToolkit('IMESSAGE', {
name: 'iMessage',
description:
"Send and read iMessages, look up contacts, and remember people, locally on the user's Mac.",
tools: [sendMessage, findContact, readMessages, recallContact, rememberContact],
});
}The toolkit depends only on @composio/core, so it drops into any Composio agent. Custom tools execute in-process through session.execute, which is why they aren't on the MCP URL yet.
Wire it up
Set the eve provider on the client and register the toolkit on the session. composio.ts grows in three steps, one Composio concept each:
Create the client with the eve provider
The provider is what makes session.tools() return eve-native tools instead of raw Composio tools. Its approval policy pauses every iMessage send before the local AppleScript runs.
12345678import { Composio } from '@composio/core';import { EveProvider, requireApprovalForTools } from '@composio/experimental/eve';
export const composio = new Composio({ provider: new EveProvider({ needsApproval: requireApprovalForTools('LOCAL_IMESSAGE_SEND'), }),});Scope a session to the user
sessions.create gives this user their own toolset, already wired to the full Composio catalog.
4 unmodified lines56789104 unmodified lines provider: new EveProvider({ needsApproval: requireApprovalForTools('LOCAL_IMESSAGE_SEND'), }),});
export const session = composio.sessions.create('user_123');Register the local toolkit
Pass the custom toolkit through experimental.customToolkits, and the local iMessage tools join the catalog on the same session.
1234567891010111213import { Composio } from '@composio/core';import { EveProvider, requireApprovalForTools } from '@composio/experimental/eve';import { createImessageToolkit } from './imessage';
export const composio = new Composio({ provider: new EveProvider({ needsApproval: requireApprovalForTools('LOCAL_IMESSAGE_SEND'), }),});
export const session = composio.sessions.create('user_123');export const session = composio.sessions.create('user_123', { experimental: { customToolkits: [createImessageToolkit()] },});Now hand that session to eve. eve discovers tools from files, so defineComposioTools(session) returns the resolver that exposes session.tools(). One line:
import { defineComposioTools } from '@composio/experimental/eve';
import { session } from '../../composio';
export default defineComposioTools(session);import { defineAgent } from 'eve';
export default defineAgent({
model: 'google/gemini-2.5-flash',
});That's the whole integration. Run eve dev and talk to it. The agent can text a contact, read a thread, and act across every connected app, with auth handled in chat through COMPOSIO_MANAGE_CONNECTIONS.
Extend it: triggers
The agent can text, so anything that can wake the agent can reach your phone. Composio triggers turn an external event into an agent run: subscribe to an app event, point Composio's webhook at your app, and act on each event with the same iMessage tools.
For example, surface a Linear assignment and ask the user whether to inspect it. The first turn contains no issue title or body, so third-party text does not cross into the agent prompt before the user opts in:
import { defineChannel, POST } from 'eve/channels';
import { composio } from '../../composio';
export default defineChannel({
routes: [
POST('/webhook', async (req, { send }) => {
const { payload: event } = await composio.triggers.parse(req, {
verifySecret: process.env.COMPOSIO_WEBHOOK_SECRET,
});
if (event.userId !== 'user_123' || event.triggerSlug !== 'LINEAR_ISSUE_ASSIGNED') {
return new Response(null, { status: 202 });
}
await send(
`A verified Linear assignment event arrived. Ask whether I want to inspect it. ` +
`Do not call tools in this turn. Event reference: ${event.uuid}.`,
{
auth: {
authenticator: 'composio-webhook',
principalType: 'service',
principalId: event.userId,
attributes: { triggerSlug: event.triggerSlug },
},
continuationToken: event.uuid,
}
);
return new Response(null, { status: 202 });
}),
],
});Point Composio at your webhook and create the trigger, once each. Reuse the same composio client from composio.ts:
import { composio } from '../composio';
// Register the webhook URL once per project; store the returned
// secret as COMPOSIO_WEBHOOK_SECRET.
const subscription = await composio.triggers.setWebhookSubscription({
webhookUrl: `${process.env.APP_URL}/webhook`,
});
// Use the exact slug from the Composio triggers catalog.
const trigger = await composio.triggers.create('user_123', 'LINEAR_ISSUE_ASSIGNED');
console.log(`Trigger created: ${trigger.triggerId}`);Run it once with npx tsx agent/setup-triggers.ts, and the webhook handler above takes over from there.
Swap the trigger and the prompt to create another reflex. Keep the first turn content-free, then fetch third-party content only after the user asks to continue.
More reflexes
The prompt is where each reflex earns its keep, but the webhook should not turn untrusted content into instructions. Here is the same two-step gate for Gmail:
import { defineChannel, POST } from 'eve/channels';
import { composio } from '../../composio';
export default defineChannel({
routes: [
POST('/webhook', async (req, { send }) => {
const { payload: event } = await composio.triggers.parse(req, {
verifySecret: process.env.COMPOSIO_WEBHOOK_SECRET,
});
if (event.userId !== 'user_123' || event.triggerSlug !== 'GMAIL_NEW_GMAIL_MESSAGE') {
return new Response(null, { status: 202 });
}
await send(
`A verified Gmail event arrived. Ask whether I want to inspect the email. ` +
`Do not call tools in this turn. Event reference: ${event.uuid}.`,
{
auth: {
authenticator: 'composio-webhook',
principalType: 'service',
principalId: event.userId,
attributes: { triggerSlug: event.triggerSlug },
},
continuationToken: event.uuid,
}
);
return new Response(null, { status: 202 });
}),
],
});If the user continues, the agent can fetch the email in a separate turn and require approval before any side effect. A few more patterns worth wiring up with the same gate:
- Stand-up nudge. A calendar event ten minutes out texts you the agenda and the meeting link, pulled straight from the invite.
- Review request. A new GitHub review request texts you the PR title and a one-line read of the diff, so you can reply "approve" from your phone.
- Money in. A successful Stripe payment texts you the amount and the customer, no dashboard required.
- Cover for me. A Slack mention while you're away texts a teammate and asks them to take a look.
Use the exact slug from the Composio triggers catalog for each. Signature verification proves the event came through Composio; it does not make an email subject, issue title, or other third-party text trustworthy. Reject unexpected users and trigger slugs, keep external content out of the initial prompt, and require approval before side effects such as sending a message.
Browse the project
The key files, in one place. The custom toolkit and the session wiring carry the integration, the eve provider is imported from @composio/experimental/eve, and the rest is local macOS glue.
12345678910111213141516171819202122232425262728293031323334353637import { experimental_createTool } from "@composio/core";import { z } from "zod/v3";import { runAppleScript } from "./applescript";
const SEND_SCRIPT = `on run {targetBuddy, targetText} tell application "Messages" set targetService to 1st service whose service type = iMessage send targetText to buddy targetBuddy of targetService end tellend run`;
// "Think" time + time to type the message, with jitter, capped so messages// don't fire instantly.function humanDelayMs(text: string): number { const think = 800 + Math.random() * 1700; // 0.8–2.5s const typing = text.length * (40 + Math.random() * 40); // ~40–80ms/char return Math.min(think + typing, 12_000);}
export const sendMessage = experimental_createTool("SEND", { name: "Send iMessage", description: "Send an iMessage from the user's Mac to a phone number or iMessage email handle.", preload: true, inputParams: z.object({ to: z .string() .describe("Recipient handle: phone number (e.g. +15551234567) or iMessage email."), text: z.string().describe("Message body to send."), name: z.string().optional().describe("Recipient's contact name, for display."), }), execute: async ({ to, text }) => { await new Promise((r) => setTimeout(r, humanDelayMs(text))); await runAppleScript(SEND_SCRIPT, [to, text]); return { sent: true, to }; },});The complete, runnable project will be published in the Composio examples repo.
Run it
The browser above is an implementation slice, not a standalone fixture: it omits the project's package manifest and supporting handles, chat-db, and memory modules. The complete runnable project will be published in the Composio examples repo. Until then, use the provider walkthrough on this page in an existing eve app and treat the iMessage source as a reference for the local toolkit. The first send or contact lookup prompts for macOS permission, and the first time the agent needs an app you haven't connected, it returns an auth link in chat through COMPOSIO_MANAGE_CONNECTIONS.