Skip to content

Custom Agents: integration guide (Beta)

This is the complete guide to showing an AI agent you built in the Vibe Island notch. It is written so a coding agent can follow it step by step. The integration is one shell command per event: no SDK, no hooks system needed, and the command never fails your agent (it exits 0 whether or not Vibe Island is running and does not wait for a reply). Beta: needs Vibe Island 1.0.53 or newer, direct download edition.

Do the work yourself, in the user’s project: edit the agent’s own code or hook configuration (Vibe Island never edits it for you), and never just hand the user a list of commands. Stop and ask the user only at the points marked “Ask the user” or “Stop and tell the user”; otherwise keep going. Never edit anything under ~/.vibe-island/ except the one optional manifest file in Step 5. Every step ends with “Check passed when …”; do not move on before it holds. When you are done, print the report from Step 7.

Pick a name for your agent (lowercase letters, digits, - or _; the examples use acme-coder), make sure Vibe Island is running, and run these one at a time. Move the pointer to the notch to open the panel.

Terminal window
echo '{"hook_event_name":"SessionStart","session_id":"demo-1","cwd":"/tmp"}' | ~/.vibe-island/bin/vibe-island-bridge --source acme-coder

A card for tmp appears, carrying your agent’s name (as a text tag, or as an A badge you can hover).

Terminal window
echo '{"hook_event_name":"Stop","session_id":"demo-1","cwd":"/tmp","last_assistant_message":"Done."}' | ~/.vibe-island/bin/vibe-island-bridge --source acme-coder

The card shows the completion notice Done.

Terminal window
echo '{"hook_event_name":"SessionEnd","session_id":"demo-1","cwd":"/tmp"}' | ~/.vibe-island/bin/vibe-island-bridge --source acme-coder

The card goes away. Your agent sends the same three events when a session opens, a turn ends and the session closes.

Terminal window
# Edition: a Mac App Store install carries a receipt.
[ -e "/Applications/Vibe Island.app/Contents/_MASReceipt/receipt" ] && echo "edition: Mac App Store" || echo "edition: direct download"
# The command exists and is executable.
BRIDGE="$HOME/.vibe-island/bin/vibe-island-bridge"
[ -x "$BRIDGE" ] && echo "command: ok" || echo "command: MISSING"
# Vibe Island is running.
pgrep -x vibe-island >/dev/null && echo "app: running" || echo "app: NOT RUNNING"
# Installed version (works when the app is in /Applications).
echo "version: $(defaults read "/Applications/Vibe Island.app/Contents/Info" CFBundleShortVersionString 2>/dev/null || echo unknown)"
ResultStop and tell the user
edition: Mac App StoreCustom agents are not supported in the Mac App Store edition yet.
command: MISSINGVibe Island is not installed or was never opened. Install the latest version from vibeisland.app and open it once. After Remove All Auto-Configuration (Settings > About), turn an integration back on in Settings > Integrations.
app: NOT RUNNINGOpen Vibe Island. Events sent while it is closed are dropped.
version: below 1.0.53Update Vibe Island to 1.0.53 or newer. If it says unknown, ask which version is installed.

The edition and version checks assume the app is at /Applications/Vibe Island.app. If it is installed elsewhere, ask the user for the version and edition instead of guessing.

Check passed when the edition is the direct download, the command is executable, the app is running, and the version is 1.0.53 or newer.

The source name is your agent’s identity: the value of --source, the file name of the optional manifest, and the prefix of every session id. Pick it from the agent’s product name (or keep the user’s own name for it) and keep it for good.

  • It matches [a-z][a-z0-9_-]{0,63}: lowercase letters, digits, _ and -, starting with a letter.
  • It is not a built-in agent’s name. The check below lists them; the list grows, so put your product or company in the name.
  • It has no _custom_ in it: a built-in name plus _custom_, such as claude_custom_0, is reserved.
  • Prefer 12 characters or fewer: a longer name can send a click to the wrong tab in Ghostty, see Jumping back to your app.

Good: acme-coder, lint_bot. Bad: claude (handled as that built-in agent), Acme Coder (uppercase, a space; use the manifest’s display name), 2fast (starts with a digit).

Terminal window
SOURCE="acme-coder"
printf '%s' "$SOURCE" | grep -Eq '^[a-z][a-z0-9_-]{0,63}$' && echo "format: ok" || echo "format: INVALID"
case "$SOURCE" in
claude|codex|zcode|gemini|antigravity|cursor|trae|opencode|mimocode|devin|droid|qoder|qodercn|qoderwork|qwenwork|qwen|grok|copilot|vscodeagent|codebuddy|workbuddy|kiro|kimi|kimicode|deepseek|dsh|mistralvibe|gajaecode|hermes|amp|pi|ohmypi|openclaw|*_custom_*)
echo "name: RESERVED" ;;
*) echo "name: ok" ;;
esac

If the product is a fork of a built-in agent that only moved its config folder and kept its hook protocol, this guide is the wrong tool: add the folder in Settings > Integrations > CLI Hooks instead.

Check passed when both lines say ok.

Your agent does not need a hooks system: run the command from wherever your code knows the event happened, whether that is a hook, a callback, an event handler, or a wrapper script around your agent. Map the moments in its lifecycle to events. The smallest set that works is SessionStart plus Stop.

Moment in your agentEventWhat the user sees
A session opensSessionStartThe card appears, idle. Minimum set.
The user sends a messageUserPromptSubmitWorking; the prompt is the summary.
A tool call starts or endsPreToolUse, PostToolUseWorking, with the tool name.
The agent needs an approvalNotification with "notification_type":"permission_prompt"A read-only reminder, see Approval reminders.
The turn finishesStopIdle, and a completion notice with the reply if you send last_assistant_message. Minimum set.
The turn failsStopFailureIdle.
The session closesSessionEndThe card is removed.

Call the command from the agent’s own process, in its own terminal environment: from a detached background service it still reports status, but a click on the card cannot return to the agent’s terminal. The agent must run as a local process; a packaged desktop app counts, a page in a browser tab does not.

Check passed when you can name the code location for SessionStart, Stop and SessionEnd.

Replace acme-coder with your source name and demo-1 with your session id. Put cwd in every event: after Vibe Island restarts, the next event of a running session brings its card back, and cwd names it.

Terminal window
# Session opened
echo '{"hook_event_name":"SessionStart","session_id":"demo-1","cwd":"/tmp"}' | ~/.vibe-island/bin/vibe-island-bridge --source acme-coder
# User sent a message
echo '{"hook_event_name":"UserPromptSubmit","session_id":"demo-1","cwd":"/tmp","prompt":"Fix the failing login test"}' | ~/.vibe-island/bin/vibe-island-bridge --source acme-coder
# Tool call started
echo '{"hook_event_name":"PreToolUse","session_id":"demo-1","cwd":"/tmp","tool_name":"Bash","tool_input":{"command":"npm test"}}' | ~/.vibe-island/bin/vibe-island-bridge --source acme-coder
# Tool call ended
echo '{"hook_event_name":"PostToolUse","session_id":"demo-1","cwd":"/tmp","tool_name":"Bash"}' | ~/.vibe-island/bin/vibe-island-bridge --source acme-coder
# Turn finished
echo '{"hook_event_name":"Stop","session_id":"demo-1","cwd":"/tmp","last_assistant_message":"Fixed the login test."}' | ~/.vibe-island/bin/vibe-island-bridge --source acme-coder
# Turn failed
echo '{"hook_event_name":"StopFailure","session_id":"demo-1","cwd":"/tmp","last_assistant_message":"The model API returned an error."}' | ~/.vibe-island/bin/vibe-island-bridge --source acme-coder
# Needs approval (read-only reminder)
echo '{"hook_event_name":"Notification","session_id":"demo-1","cwd":"/tmp","notification_type":"permission_prompt","tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' | ~/.vibe-island/bin/vibe-island-bridge --source acme-coder
# Session closed
echo '{"hook_event_name":"SessionEnd","session_id":"demo-1","cwd":"/tmp"}' | ~/.vibe-island/bin/vibe-island-bridge --source acme-coder

In the agent’s code, send events through one helper that builds the JSON with a serializer, sends each session’s events in order, and gives the command a short timeout. This Node.js helper does that; port it to the project’s language.

// Node.js 18 or newer. Events are sent one at a time, in order, and never throw.
import { spawn } from "node:child_process";
import os from "node:os";
import path from "node:path";
const BRIDGE = path.join(os.homedir(), ".vibe-island", "bin", "vibe-island-bridge");
const SOURCE = "acme-coder";
let queue = Promise.resolve();
function send(event) {
return new Promise((resolve) => {
try {
const child = spawn(BRIDGE, ["--source", SOURCE], { stdio: ["pipe", "ignore", "ignore"] });
const timer = setTimeout(() => child.kill(), 2000);
const done = () => { clearTimeout(timer); resolve(); };
child.on("error", done);
child.on("exit", done);
child.stdin.on("error", () => {});
child.stdin.end(JSON.stringify(event));
} catch {
resolve();
}
});
}
export function emit(event) {
queue = queue.then(() => send(event));
}
emit({ hook_event_name: "SessionStart", session_id: "demo-1", cwd: process.cwd() });

Check passed when every event the agent can produce goes through the helper and a failure in it cannot stop the agent.

Skip this step unless you need a display name, a jump rule, custom event or field names, a custom stdout, or automatic turn end for a bot. Standard events need no file.

Path: ~/.vibe-island/integrations/<source>.json. The command re-reads it on every call, and Vibe Island picks up changes within a few seconds, without restarting anything. The smallest useful file is {"displayName": "Acme Coder"}.

KeyTypeEffectDefault
displayNamestringThe name on the card: a text tag, or a first-letter badge with the full name on hover, per the user’s display setting.The source name, each word capitalized (Acme-Coder).
stdoutResponsestringPrinted to stdout on every call; an empty string prints an empty line.{}
fieldMapobject of stringsRenames top-level payload fields, {"yourName": "standard_name"}. Applied first.none
eventMapobject of stringsTranslates event names, {"your_event": "Stop"}. Exact match, case included. Applied after fieldMap.none
terminatingToolsarrayEnds the turn when a tool call finishes, see below.none
bundleIdentifier, jumpRulestring, objectYour app’s bundle identifier and {"method":"urlScheme","template":"..."}, to land on the right conversation: Jumping back to your app.none

A key with the wrong type is ignored. A file that is not valid JSON, not an object, or larger than 1 MiB is ignored as a whole; the command then works with the defaults.

A manifest for an agent whose payload uses different names:

{
"displayName": "Acme Coder",
"stdoutResponse": "",
"fieldMap": {
"event": "hook_event_name",
"conversation_id": "session_id",
"working_directory": "cwd"
},
"eventMap": {
"task_completed": "Stop",
"tool_call": "PreToolUse"
},
"terminatingTools": [
"WeComReply",
{ "name": "SlackPostMessage", "when": { "is_final": true } }
]
}

With that file, {"event":"task_completed","conversation_id":"c-1","working_directory":"/tmp"} is delivered as a Stop event for session c-1 in /tmp.

  • fieldMap and eventMap see top-level keys only. If both the old and the new field are present, the old one wins.
  • Standard spellings need no map. An event without hook_event_name after mapping is skipped.
  • terminatingTools is for agents that never send Stop, such as a bot that calls a “send reply” tool and then waits. After a matching PostToolUse the command adds a Stop. A rule matches when tool_name equals name and every field in when (if present) equals the same top-level field of tool_input, compared by JSON type (true and 1 differ).

Check passed when the file parses (python3 -m json.tool < ~/.vibe-island/integrations/acme-coder.json) and is named exactly <source>.json.

You cannot see the notch: verify through the command and the log, then ask the user.

The command prints {} (or your stdoutResponse) and exits 0 whether or not Vibe Island is running, so exit 0 and {} do not prove delivery; if the app cannot be found at all it prints nothing (Step 1 catches that). Each call also logs one JSON line to ~/Library/Logs/VibeIsland/bridge.log with the keys t (UTC time), s (source name) and m (message). Key order varies, so match substrings.

Ask the user to watch the notch (moving the pointer to it opens the panel), then run this script. It sends a complete demo session, waits so the user can watch the card, closes it, and prints the log lines for your source.

#!/bin/sh
SOURCE="acme-coder" # your source name
BRIDGE="$HOME/.vibe-island/bin/vibe-island-bridge"
LOG="$HOME/Library/Logs/VibeIsland/bridge.log"
SID="verify-$(date +%s)" # a new id for every run
START=$(date -u +%Y-%m-%dT%H:%M:%SZ)
send() {
out=$(printf '%s' "$1" | "$BRIDGE" --source "$SOURCE")
echo "exit=$? stdout=$out"
}
entries() {
grep "\"s\":\"$SOURCE\"" "$LOG" | awk -v start="$START" '
match($0, /"t":"[^"]*"/) { t = substr($0, RSTART + 5, RLENGTH - 6); if (t >= start) print }'
}
echo "1/6 SessionStart"; send '{"hook_event_name":"SessionStart","session_id":"'"$SID"'","cwd":"/tmp"}'
sleep 3
echo "2/6 UserPromptSubmit"; send '{"hook_event_name":"UserPromptSubmit","session_id":"'"$SID"'","prompt":"Vibe Island integration check"}'
echo "3/6 PreToolUse"; send '{"hook_event_name":"PreToolUse","session_id":"'"$SID"'","tool_name":"Bash","tool_input":{"command":"echo check"}}'
sleep 3
echo "4/6 PostToolUse"; send '{"hook_event_name":"PostToolUse","session_id":"'"$SID"'","tool_name":"Bash"}'
echo "5/6 Stop"; send '{"hook_event_name":"Stop","session_id":"'"$SID"'","last_assistant_message":"Integration check finished."}'
sleep 8
echo "6/6 SessionEnd"; send '{"hook_event_name":"SessionEnd","session_id":"'"$SID"'"}'
sleep 1
PROBLEMS='connect failed|send failed|invalid JSON|missing hook_event_name|drop admission'
echo "--- log lines for $SOURCE since $START"
entries | grep -E "\"m\":\"event=|$PROBLEMS"
echo "--- events logged: $(entries | grep -c '"m":"event=') (expected 6)"
echo "--- problems: $(entries | grep -c -E "$PROBLEMS") (expected 0)"
Log line containsMeaningWhat to do
"m":"event=Stop"The event was recognized. Expect one line per call.Nothing.
connect failed or send failedVibe Island was not running or not reachable; the event was dropped. A successful delivery writes no line in release builds, so no such line after an event= line is the signal.Ask the user to open Vibe Island, then run the script again.
invalid JSON or missing hook_event_namestdin was not a JSON object, or had no event name after mapping; the event was skipped.Fix how the JSON is built, or add hook_event_name (or a fieldMap entry for it).
drop admission hookThe user blocked the app that launched the command.Ask the user to remove it in Settings > Notifications > Blocked Launcher Apps.

Ask the user to confirm, in this order:

  1. A card for tmp with an A badge or an Acme Coder tag (or your display name) appeared, idle.
  2. Three seconds later it turned to working, with the Bash tool and the summary Vibe Island integration check.
  3. Three seconds after that it went idle with the completion notice Integration check finished.
  4. Clicking the card (between the notice and the end of the script) brought the terminal window or app the agent runs in to the front.
  5. Eight seconds after the notice the card disappeared.

No card although the log is clean: compare the log’s "s" value with your source name, and check for a user rule hiding the card. A click in the wrong place: note which terminal or app the agent runs in and where it landed.

Check passed when the script prints six event= lines, zero problems and exit=0 for every call (the events reached Vibe Island; no log line says what the notch displayed), and the user confirmed the card.

  1. Stop ends a turn, not the session. Send SessionEnd when the session closes, on every exit path you control; otherwise the card lingers until idle cleanup.
  2. Use one unique id per session, the same on every event of that session, and make it a random UUID: ids that start with the same characters (chat-1, chat-2) can send a click to the wrong tab, see Jumping back to your app. Never reuse an id for a new conversation, and send nothing for an id after its SessionEnd.
  3. Never block your agent on the command: 2-second timeout, ignore the result, send each session’s events in order.
  4. Ignore stdout; do not require {}.
  5. Build the JSON with a serializer, never by joining strings.
  6. Use your own source name, and send Notification only with permission_prompt; any other Notification returns the card to idle.

Print this block for the user, filled in:

Vibe Island integration report
Source name: acme-coder
Files changed: <paths>
Events wired: <event: file and function, one per line>
Manifest: <written at ~/.vibe-island/integrations/acme-coder.json | not needed>
Verification: <n>/6 events logged, <n> problems
To confirm: <what the user still has to check by eye: card appeared, went idle, click landed on the right window>
Skipped: <anything not done, and why>

If something did not work, a requirement is not covered, or you needed a workaround, ask the user whether you should draft an email to [email protected]. You write the draft and show it; the user reviews and sends it. Never send it yourself.

  • It contains: what the agent is and does; the Vibe Island and macOS versions (defaults read "/Applications/Vibe Island.app/Contents/Info" CFBundleShortVersionString, sw_vers -productVersion); the terminal or app the agent runs in, and where a click landed if it was wrong; which events it sends and from where; the manifest without anything that looks like a secret; what you expected and what happened; the relevant bridge.log lines (grep '"s":"acme-coder"' ~/Library/Logs/VibeIsland/bridge.log | tail -n 40).
  • It never contains: API keys, tokens or passwords; prompts, replies or other conversation content (including prompt, last_assistant_message and the text of tool_input); private file paths or project names the user has not agreed to share.

Read a section here only when a step above links to it.

Event names use the Claude Code spelling. Other common styles are converted: sessionStart, session_start, session-start and SESSION_START are all SessionStart. A name that is not recognized passes through unchanged; use eventMap to translate it. PermissionResult is the exception: it is not converted, so send it exactly as written.

EventUseful fieldsEffect on the card
SessionStartsession_id, cwdCreates the card, idle.
UserPromptSubmitpromptWorking; prompt becomes the summary.
PreToolUsetool_name, tool_inputWorking, with the tool name.
PostToolUsetool_nameWorking.
PostToolUseFailuretool_nameWorking.
Stoplast_assistant_messageIdle, with a completion notice when the reply is sent.
StopFailurelast_assistant_messageIdle.
SessionEndnoneThe card is removed.
Notificationnotification_type, tool_name, tool_input, messageWith permission_prompt, an approval reminder. Any other type returns the card to idle.
PermissionResultnoneLeaves the approval state; a tool event, Stop or the next prompt also clears it.

Send a JSON object on stdin with snake_case names. Other common styles are converted at the top level only (sessionId and session-id become session_id); keys inside tool_input are left alone. An unrecognized name passes through; use fieldMap. Names that start with an underscore are reserved: do not send them.

FieldTypeRequiredNotes
hook_event_namestringYesThe event name; without it the event is skipped.
session_idstringYes in practiceIdentical on every event of a session. If missing, Vibe Island generates an unstable fallback id. See Session ids.
cwdstringRecommendedThe card’s project name; a hint passed to jump rules.
promptstringNoThe user’s message, for UserPromptSubmit.
tool_namestringNoFor tool events and permission reminders.
tool_inputobjectNoTool arguments such as {"command":"ls"}; a short summary shows on the card.
last_assistant_messagestringNoThe reply, for Stop and StopFailure; long text is shortened.
notification_typestringNopermission_prompt for approval reminders.
messagestringNoFree text for Notification.
  • Stop ends a turn; the next turn updates the same card. SessionEnd removes the card, and a late event for that id can bring it back. The command process exiting does not end the session.
  • A card goes away only when SessionEnd arrives, the user removes it, a remote host’s SSH connection drops, or idle cleanup removes it: a card idle (not working) for longer than the cleanup time, 2 hours by default, set in Settings > General > Idle session cleanup (30 minutes to 24 hours, or off).
  • A session that shows as working but sends no event for 4 hours returns to waiting for input; the card stays. Send tool events during long tasks.
  • A new session with the same source in the same terminal tab replaces the older card. After Vibe Island restarts, earlier custom sessions are not restored; one reappears with its next event.

If your agent has its own approval flow, tell Vibe Island that the user has to go back and approve, with the “Needs approval” command in Step 4. It is a reminder only: nothing is returned, and your agent decides in its own interface.

The card shows waiting for approval with the tool name and a short summary; a click takes the user back to your agent (see Jumping back to your app). The next event your agent sends (a tool event, PermissionResult, Stop, StopFailure, UserPromptSubmit or SessionEnd) clears it. Send one reminder per approval, and do not send PermissionRequest; it is not part of this interface.

Every call prints one line, {} or your stdoutResponse, the same for every event and whether or not it was delivered. Empty or invalid stdin prints {} and sends nothing; if the app cannot be found nothing is printed. The exit code is 0 except for a missing --source (1). Delivery is one-way and best effort.

Ignore stdout. If your host puts hook stdout into a model’s context or retries on some output, set stdoutResponse to something harmless, such as an empty string. If your code does read it, treat anything it does not explicitly recognize as “no decision”: its content may grow in future versions.

You send session_id; Vibe Island prefixes it with your source name, so {session_id} in a jump rule is <source>-<id>: demo-1 becomes acme-coder-demo-1, and an id that already starts with acme-coder- is not prefixed twice. {raw_session_id} is exactly what you sent (demo-1), on this Mac and for remote hosts; use it to find the session in your own app. Two ids that differ only by the prefix are the same session.

Verified on a real Mac with the build that ships this feature:

  • A command-line agent in Terminal.app, iTerm2 or Ghostty: clicking its card lands on the window and tab the agent runs in. That includes a minimized iTerm2 window (restored and focused), several tabs in one iTerm2 or Ghostty window, and a tmux pane inside Ghostty.
  • An agent that is its own macOS app, without a jump rule: clicking its card brings that app to the front.
  • The same app with a jump rule in its manifest: the app is brought to the front and then receives the URL. {raw_session_id} is the id the developer sent and {session_id} is <name>-<id>.

Known issues in 1.0.53:

  • Ghostty with several tabs running the same custom agent: a click can land on another tab of that agent when the session ids start with the same characters (chat-1, chat-2), or when the source name is longer than 12 characters. Random UUIDs as session ids and a source name of 12 characters or fewer avoid it.
  • When a terminal tab is closed and the agent did not send SessionEnd, its card stays until idle cleanup or until the user removes it.
  • A jump rule whose URL scheme no installed app handles makes macOS show a dialog saying no application is set to open the URL.

Not yet verified for custom agents: WezTerm, kitty, Warp, Electron-style apps where the agent runs in a helper process, and agents on SSH remotes. Ask the user to report what they see.

To land on the exact conversation inside your own app, add bundleIdentifier and jumpRule to the manifest and register a URL scheme in your app; Vibe Island opens it with the session hints filled in (variables and a handler example: Custom Jump Rules). When you test a jump rule, note that macOS only routes a custom URL scheme to an app that is properly installed and signed. An unsigned build run from a temporary folder does not receive the URL even though Vibe Island sends it, so test with a normally built app in a normal location.

Agents on a host connected through Vibe Island SSH Remote can appear in the same notch; this has not been verified for custom agents yet. The manifest keys eventMap, fieldMap, terminatingTools and stdoutResponse do not apply to them, so send standard names. Remote cards are removed when the SSH connection drops. For remote setup, email [email protected].

What is listed under Works today is what we support.

  • Works today: live status, tool name and completion notice; your own name as a text tag or a first-letter badge; a click on the card returns to the terminal or app the agent runs in; read-only approval reminders; the optional manifest (display name, event and field mapping, automatic turn end for bots, jump rule).
  • Planned: answer approvals and questions from the notch.
  • Not supported: sub-sessions of an agent (no parent and child grouping); an agent’s own usage or quota source; sending messages to the agent from the notch; a bypass button; automatic session naming; custom icons (the name shows as a text tag or a first-letter badge in a neutral color); restoring sessions after Vibe Island restarts; the Mac App Store edition.

The event names, field names and manifest keys in this guide are stable. They are not removed or renamed and their meaning does not change. New abilities arrive as new events, fields or manifest keys, so an integration written against this guide keeps working.