Skip to main content

Creating a Toby plugin

Toby integrations ship as installable plugins. All new plugins must be TypeScript package plugins (bun-package format) — a directory with a manifest.json and TypeScript entrypoint, executed via Toby's bundled Bun runtime. No compilation step required.

The only native macOS code in the Toby product is the Toby.app itself. When a plugin needs macOS framework access (EventKit, Shortcuts, system APIs, TCC-protected resources), the TypeScript plugin delegates those operations to Toby.app's native API server rather than compiling its own native binary. See the macOS and Apple Calendar plugins for reference.

Plugins implement a protocol v1 contract: Toby passes credentials and session state on stdin and reads JSON on stdout. Plugins must not read or write ~/.toby/ directly.

Choosing a plugin type

TypeScript package pluginBinary plugin (legacy)
FormatDirectory with manifest.json + .ts entrypointSingle compiled executable file
RuntimeToby's bundled Bun runtimeNone — the binary is self-contained
Build stepNone (install the directory directly)Compile ahead of time
Dependenciespackage.json + node_modules/ (vendored or installed when discovered)Linked at compile time
Best forAll new plugins — API integrations, web services, and macOS system controls routed through Toby.appExisting compiled binaries only — do not create new ones
ReferenceSample TypeScript plugin, macOS, Apple Calendar(none — all first-party plugins migrated to bun-package)

Rule of thumb: Always use a TypeScript package plugin. For macOS system controls and Calendar/EventKit access, route through Toby.app's native API server from a TypeScript plugin rather than building a native binary.

TypeScript package plugins (bun-package)

A TypeScript package plugin is a directory containing a manifest.json, a package.json, and a TypeScript entrypoint. Toby discovers the directory, reads the manifest, and invokes the entrypoint with the protocol arguments, with cwd set to the plugin directory.

Directory layout

my-plugin/
manifest.json # required — plugin metadata and runtime config
package.json # recommended — dependency declarations
src/index.ts # entrypoint declared in manifest
node_modules/ # optional — vendored dependencies

The plugin name comes from the name field in manifest.json (not the directory name). Installed plugins live as ~/.toby/plugins/toby-plugin-<name>/.

Manifest format (manifest.json)

{
"name": "myapp",
"displayName": "My App",
"description": "Short description for status and configure",
"version": "1.0.0",
"protocolVersion": "1",
"runtime": {
"type": "bun",
"entry": "src/index.ts"
},
"capabilities": ["chat"],
"providerCategories": ["tasks"]
}
FieldRequiredMeaning
nameyesIntegration id (must match ^[a-z0-9_-]+$)
displayNameyesHuman label in the Integrations UI
descriptionyesOne-line summary
versionyesPlugin release version
protocolVersionyesMust be "1" for this spec
runtime.typeyesMust be "bun"
runtime.entryyesPath to the TypeScript entrypoint, relative to the plugin directory
capabilitiesnoUsed for fast discovery filtering; status is the runtime source of truth. Default ["chat"]
providerCategoriesnoe.g. email, calendar, tasks, contacts, chat, documents, work_tracker

Bun runtime resolution

Toby resolves the Bun runtime in the following order:

  1. TOBY_BUN_PATH environment variable (explicit override)
  2. ~/.toby/helpers/bun (bundled in release installs)
  3. bun on PATH (development mode)

Release builds bundle a Bun binary so TypeScript plugins work without a user-installed global bun.

Install for local use

First-party plugins are already installed with Toby.app. For a custom plugin:

  1. Copy (or symlink) your plugin directory to ~/.toby/plugins/toby-plugin-<name>/.
  2. Ensure manifest.json has a valid name and runtime entry.
  3. Restart Toby.app (or use /restart-server in chat) so discovery reloads.
  4. Open Integrations — your plugin should appear by its display name.
  5. Enter credentials, click Connect, and try tools in chat.

If node_modules/ is not present, Toby may install dependencies with the bundled Bun runtime when it first loads the plugin. For production plugins, vendor node_modules/ so install does not need network access.

Invocation (protocol)

Toby invokes the entrypoint with the same argv matrix as binary plugins. You normally do not run these yourself—the app does when you open Integrations, click Connect, or use tools in chat:

<bun-path> run ./src/index.ts status
<bun-path> run ./src/index.ts tools list
<bun-path> run ./src/index.ts tools execute

The JSON protocol (stdin/stdout/stderr, exit codes, subcommands) is identical to binary plugins. See the protocol subcommands section below for the full contract.

Binary plugins (legacy)

Binary plugins are standalone executables that Toby spawns directly. They are language-agnostic — any compiled binary works as long as it implements the protocol. Do not create new binary plugins. All new plugins must be TypeScript package plugins.

Legacy format

Binary plugins exist for historical reasons. All first-party plugins have been migrated to TypeScript bun-package format. For macOS framework access, use a TypeScript plugin that delegates to Toby.app's native API server.

For each operation (connect, list tools, run a tool, …), Toby spawns your binary once, passes optional JSON on stdin, reads one JSON object from stdout, and checks the exit code:

~/.toby/plugins/toby-plugin-myapp <command> [subcommand]

Examples of what Toby runs internally:

~/.toby/plugins/toby-plugin-myapp status
~/.toby/plugins/toby-plugin-myapp connect # stdin: config envelope
~/.toby/plugins/toby-plugin-myapp config shape
~/.toby/plugins/toby-plugin-myapp tools list
~/.toby/plugins/toby-plugin-myapp tools execute # stdin: tool request JSON

After the response is parsed, the subprocess exits. Chat tools and connect flows all use this same one-shot pattern.

Plugin naming and discovery

Both plugin formats use the same naming convention and discovery locations.

RuleDetail
Nametoby-plugin-<name> where <name> matches ^[a-z0-9_-]+$ (this becomes the integration id). For TypeScript plugins, <name> comes from manifest.json. For binary plugins, it's the filename.
Location~/.toby/plugins/ (or $TOBY_DIR/plugins/ when TOBY_DIR is set)
Binary permissionsBinary plugins must be executable
CollisionsThe name must not match an existing built-in integration

Toby discovers plugins primarily from ~/.toby/plugins/ after install. Place your plugin there and restart the app (or restart the local service from chat) to pick up changes.

Streams, exit codes, and limits

stdin, stdout, stderr

StreamRule
stdinUTF-8 JSON when the subcommand requires it; empty or omitted otherwise
stdoutExactly one JSON object, then exit—no log prefixes, banners, or trailing text
stderrHuman-readable diagnostics only; Toby may show stderr on failure but never parses it

When stdin is empty for envelope-based commands, treat config and state as {}.

Exit codes

CodeMeaning
0Success (ok: true in JSON)
1Business failure (ok: false)
2Contract or usage error (bad argv, malformed JSON, unknown subcommand)

Unknown commands or invalid usage should exit 2 with JSON like { "ok": false, "error": "…", "code?": "…" }.

Timeouts

Each subprocess has a 120 second timeout and 4 MiB stdout limit. Long-running API work must finish within that window.

Config envelope (stdin)

Many subcommands read a JSON object from stdin:

{
"config": {
"apiKey": "example"
},
"state": {
"connectedAt": "2026-05-31T12:00:00.000Z"
}
}
FieldMeaning
configCredential and integration fields Toby stores in credentials.json (namespaced as <name>.<key> in the Integrations UI)
stateSession fields Toby stores in config.json for this integration
validateToolsOptional on status only—when true, return per-tool health rows (see status)

Protocol subcommands

Every plugin should implement the subcommands in this table. Toby invokes them when you use Integrations, Connect, or chat tools.

argvstdinstdoutWhen Toby runs it
statusOptional config envelopeStatus responseIntegrations list/detail, health checks
connectConfig envelope{ ok, reason?, config? }Connect button in Integrations
disconnectOptional config envelope{ ok, reason?, config? }Disconnect button in Integrations
config shape(none){ ok, fields? }Integrations field definitions
config getConfig envelope{ ok, config? }Normalized credential readback
config setConfig envelope{ ok }Optional hook after Toby saves credentials
tools list(none){ ok, tools? }Chat tool catalog
tools executeTool requestTool responseChat tool execution
setupOptional config envelopeSetup responseOptional one-time setup from the integration detail
setup guideOptional config envelopeSetup guide responseSetup Guide wizard in Toby.app

status

Reports plugin identity, protocol version, connection state, and metadata used by the Integrations UI and health checks.

stdin: optional config envelope.

stdout (success):

{
"ok": true,
"name": "myapp",
"displayName": "My App",
"description": "Short description for status and configure",
"version": "1.0.0",
"protocolVersion": "1",
"connected": true,
"capabilities": ["chat"],
"providerCategories": ["tasks"],
"details": "API key configured."
}
FieldRequiredMeaning
okyesMust be true on success
nameyesIntegration id (matches plugin name)
displayNameyesHuman label in the Integrations UI
descriptionyesOne-line summary
versionyesPlugin release version
protocolVersionyesMust be "1" for this spec
connectedyesWhether Toby should treat the integration as connected
capabilitiesnoDefault ["chat"]. May include "inbound" for @mention listening
providerCategoriesnoe.g. email, calendar, tasks, contacts, chat, documents, work_tracker
detailsnoExtra status text
resourcesnoArbitrary tags for status output
setupAvailablenotrue when the plugin implements setup
setupDescriptionnoShort label for setup in the Integrations UI

Optional extensions on status:

  • authMethods — OAuth or multi-method auth:

    "authMethods": [
    { "id": "oauth_pkce", "label": "OAuth (PKCE)", "isDefault": true },
    { "id": "api_key", "label": "API Key" }
    ]
  • chatModelPrepRequired when capabilities includes "chat". Supplies integration-specific prompt sections Toby merges with personas and global tool guidance:

    "chatModelPrep": {
    "systemPromptSection": "### My App\nShort block for multi-integration chat.",
    "singleSessionRules": "You are Toby…\nRules:\n- …",
    "singleSessionUserTemplate": "User request:\n{{userPrompt}}",
    "multiUserContentTemplate": "## My App\n…\n{{userPrompt}}"
    }

    Use {{userPrompt}} in templates where the user's message should appear.

  • chatReadiness — When stdin includes a config envelope, tell the chat UI whether the integration is ready:

    "chatReadiness": {
    "ok": false,
    "hint": "Open Integrations, save credentials, then click Connect."
    }
  • tools — When stdin has "validateTools": true, return per-tool health rows:

    "tools": [
    { "tool": "myTool", "ok": true, "details": "Reachable." }
    ]

connect

Validates configuration and confirms the integration can be used. Invoked when you click Connect in Integrations.

stdin: config envelope (required config).

stdout:

{ "ok": true, "reason": "Connected successfully." }

or

{ "ok": false, "reason": "API key is required." }

When ok is true, Toby writes connectedAt into integration state. You may return a config object to persist tokens or normalized fields (OAuth access/refresh tokens, etc.); Toby merges it into stored credentials.


disconnect

Acknowledges disconnect. Toby clears session state regardless; use this to release remote resources or wipe sensitive credential fields via a config writeback.

stdin: optional config envelope.

stdout:

{ "ok": true, "reason": "Disconnected." }

config shape

Returns field definitions for Integrations → your plugin. Toby namespaces keys as <name>.<key>.

stdin: none.

stdout:

{
"ok": true,
"fields": [
{
"key": "apiKey",
"label": "API Key",
"type": "string",
"required": true,
"masked": true
}
]
}
Field propertyMeaning
keyLocal field id (Toby prefixes with integration name)
labelUI label
typestring, number, boolean, or select
required, masked, multilineOptional UI behavior
optionsRequired for select type
default, pattern, minLength, maxLength, descriptionOptional validation and help text
showForAuthMethodsOptional list of auth method ids—show field only for those methods

config get

Optional normalization hook. Toby already stores values from the Integrations UI; this subcommand lets the plugin return cleaned or inferred config.

stdin: config envelope.

stdout:

{
"ok": true,
"config": { "apiKey": "example", "authMethod": "api_key" }
}

config set

Optional sync hook after Toby saves credentials (remote registration, hydration, etc.).

stdin: config envelope.

stdout:

{ "ok": true }

tools list

Returns the chat tool catalog for this integration.

stdin: none.

stdout:

{
"ok": true,
"tools": [
{
"name": "myappEcho",
"description": "Echo a message back",
"readOnly": true,
"inputSchema": {
"type": "object",
"properties": {
"message": { "type": "string", "description": "Text to echo" }
},
"required": ["message"]
}
}
]
}

Each tool requires name, description, and inputSchema (JSON Schema object root with properties, required, and primitive types). Optional: readOnly (default false). Mark read-only tools when they do not mutate remote state; Toby may cache their results within a chat turn.


tools execute

Runs one tool during chat.

stdin:

{
"tool": "myappEcho",
"input": { "message": "hello" },
"config": { "apiKey": "example" },
"state": {},
"dryRun": false
}
FieldMeaning
toolTool name from tools list
inputArguments matching the tool's inputSchema
config / stateCurrent integration config and session state
dryRunWhen true, mutating tools should preview changes without applying them

stdout (success):

{
"ok": true,
"result": { "echo": "hello" },
"appliedActions": ["Echoed message"]
}
FieldMeaning
resultJSON value returned to the model (structure is up to your plugin)
appliedActionsHuman-readable lines describing side effects (shown in the chat transcript)
configOptional writeback (token refresh, updated remote ids, etc.)

stdout (failure):

{
"ok": false,
"error": "Unknown tool: missing"
}

Honor dryRun for mutating tools. Return appliedActions whenever the tool would change something in production mode.


setup (optional)

For one-time setup (installing macOS Shortcuts, downloading models, etc.). Advertise on status with setupAvailable: true and optional setupDescription.

stdin: optional config envelope.

stdout:

{
"ok": true,
"actions": [
{
"id": "step-one",
"label": "Install helper shortcut",
"ok": true,
"skipped": true,
"detail": "Already installed."
},
{
"id": "step-two",
"label": "Open import dialog",
"ok": true,
"detail": "Complete the import in Shortcuts.app."
}
]
}
Action fieldRequiredMeaning
idyesStable machine id
labelyesHuman-readable step name
okyesStep succeeded (including already satisfied when skipped: true)
skippednoStep not run because prerequisites are already met
detailnoExtra explanation for the user

Top-level ok: true means the setup command ran. Use top-level ok: false only for fatal errors. Individual step failures can set ok: false on that action while top-level ok stays true.

Setup is idempotent—plugins detect whether work is already done; Toby does not persist setup completion separately.


setup guide (optional)

Provide a guided onboarding experience for Toby.app. When a user opens an integration and taps Setup Guide, Toby runs setup guide on the plugin and renders the returned steps.

stdin: optional config envelope.

stdout:

{
"ok": true,
"name": "myapp",
"displayName": "My App",
"description": "Short description for the wizard header",
"steps": [
{
"id": "overview",
"title": "What My App can do",
"description": "One or two sentences about the integration."
},
{
"id": "provider",
"title": "Create credentials in the provider console",
"links": [
{ "label": "Open provider console", "url": "https://example.com/apps" }
],
"artifacts": [
{
"id": "redirectUri",
"label": "Redirect URI",
"value": "http://localhost:9876/callback",
"hint": "Paste this into the provider's OAuth redirect settings."
}
]
},
{
"id": "credentials",
"title": "Add credentials",
"description": "Paste the API key or client secret into the fields below."
},
{
"id": "validate",
"title": "Validate",
"description": "Toby will run a health check to confirm the integration is ready."
}
]
}
Step fieldRequiredMeaning
idyesStable machine id
titleyesHeading shown in the wizard
descriptionnoLonger explanation
linksnoArray of { label, url } buttons
artifactsnoArray of { id, label, value, hint? } copyable values

If your plugin does not implement setup guide, Toby builds a generic guide from status, config shape, and authMethods. Custom guides are especially useful for OAuth integrations so users know exactly which redirect URI and scopes to use.


Inbound chat (optional, advanced)

Plugins that listen for @mentions or DMs in a chat platform declare "inbound" in capabilities (in addition to "chat" for tools). Inbound uses a different transport: a long-lived subprocess and newline-delimited JSON (NDJSON), not the one-shot contract above.

toby-plugin-<name> inbound run
StreamRule
stdinOne JSON object per line (messages from Toby)
stdoutOne JSON object per line (messages to Toby)
stderrDiagnostics only

Toby starts inbound run when inbound is enabled for that integration and the local service is running. The process stays alive until Toby sends { "type": "shutdown" } or the service stops.

Optional status.inboundPrep metadata:

"inboundPrep": {
"externalKeyFormat": "slack:{teamId}:{channelId}:{threadRootTs}",
"transportLabel": "socket_mode"
}

Plugin → Toby (stdout lines):

typeMeaning
readyTransport connected
eventNormalized inbound user message
personaAppendixResponse to a persona appendix request
errorFatal transport error

Toby → plugin (stdin lines):

typeMeaning
startInitial config, state, dryRun—connect transport, then emit ready
configCredential patch while running
deliverReplyPost assistant reply to a conversation
deliverAskUserPost an askUser prompt
statusUpdate / statusClearTransient status UI in the chat platform
getPersonaAppendixRequest persona-specific appendix text for a turn
shutdownStop and exit

See the Slack integration guide for a full inbound user setup, and the Slack plugin in the Toby repository for a reference implementation.

Test your plugin in Toby.app

StepWhere
Confirm discoveryIntegrations sidebar — your display name appears
Enter credentialsIntegration detail page (fields from config shape)
ConnectConnect button (runs connect)
Check healthStatus on the detail page and Integrations list (runs status)
Optional setupSetup action if setupAvailable is true
Onboarding wizardSetup Guide if you implement setup guide
Use toolsChat, with the integration selected or mentioned

To remove a custom plugin: quit Toby.app, delete ~/.toby/plugins/toby-plugin-<name>/, and remove any leftover entries for that name under ~/.toby/credentials.json and ~/.toby/config.json if needed.

Authoring checklist

For all plugins

  1. Implement all core subcommands with single-object JSON on stdout and stable exit codes.
  2. Accept config via stdin; never read ~/.toby/ from the plugin process.
  3. Declare at least one chat tool in tools list when capabilities includes "chat".
  4. Return chatModelPrep on status for chat-capable plugins.
  5. Honor dryRun in tools execute for mutating tools.
  6. Return appliedActions strings when tools change remote state.
  7. Install under ~/.toby/plugins/, restart Toby.app, then Connect and chat-test.
  8. Optional: implement setup and set setupAvailable on status.
  9. Optional: implement setup guide for a native-app onboarding wizard.
  10. Optional: implement inbound run when the integration should respond to @mentions.

Additional steps for TypeScript package plugins

  1. Create a manifest.json with name, displayName, description, version, protocolVersion, and runtime.type: "bun" / runtime.entry.
  2. Ensure the name field matches the desired integration id (^[a-z0-9_-]+$).
  3. Include a package.json for dependency management.
  4. Vendor node_modules/ or allow Toby to install dependencies when the plugin is first loaded.

Additional steps for binary plugins (legacy only)

Do not create new binary plugins. These steps apply only to maintaining existing compiled binaries until they are migrated to bun-package format.

  1. Name the binary toby-plugin-<name> and make it executable.
  2. Compile to a standalone executable that implements the protocol above.

Reference implementations

The Toby repository includes working plugins you can copy from (paths under apps/plugin-* in the source tree):

PluginFormatNotes
Sample TypeScript pluginTypeScript packageMinimal bun-package plugin—start here for API integrations
EmailTypeScript packageIMAP/SMTP email, auth methods, config writeback
TodoistTypeScript packageAPI key auth, task tools
SlackTypeScript packageChat tools + inbound run (Socket Mode)
JiraTypeScript packageRead-only Jira REST API integration
NotionTypeScript packageDocuments provider
Apple CalendarTypeScript packageEventKit via Toby.app native API
Apple ContactsTypeScript packageContacts via Toby.app native API
Apple RemindersTypeScript packageEventKit reminders via Toby.app native API
macOSTypeScript packageSystem controls via Toby.app native API; optional setup for Shortcuts

Release archives bundle first-party plugins into ~/.toby/plugins/ automatically with Toby.app.

Next steps