Agent Chat Bridge
Turn any AI agent chat session into an async agent.
Register a timer, shell command, or webhook — the bridge automatically resumes the session with your prompt when the trigger fires.
Supported IDEs: VS Code (GitHub Copilot Chat) · Devin (Cascade)
Core flow: agent does work → POST /jobs (one call) → ends session normally → bridge fires prompt back to that session when the trigger fires. Poll GET /jobs/:id to read the agent's reply.
Table of Contents
Quick Start
# 1. Verify the bridge is running (after IDE reload)
curl --noproxy localhost http://localhost:9801/health
# 2. Register a 10-second timer (simplest test)
curl --noproxy localhost -X POST http://localhost:9801/jobs \
-H 'Content-Type: application/json' \
-d '{"session_id":"YOUR_SESSION_ID","prompt":"Timer fired — I am back.","seconds":10}'
# → { "job_id": "uuid", ... }
# 3. Agent ends its turn. 10 seconds later the prompt appears in that session.
# 4. Poll for the agent's reply (optional — see "Reading the reply" below)
curl --noproxy localhost http://localhost:9801/jobs/JOB_ID
# → { "status": "responded", "response_text": "..." }
⚠️ Always use --noproxy localhost in IDE terminals if a corporate proxy is configured. Without it, curl routes through the proxy (a remote server) which cannot reach localhost on your machine — you get binary garbage or a proxy error instead of JSON. --noproxy localhost,127.0.0.1 tells curl to connect directly for loopback addresses.
⚠️ Default port is 9801. Double-check the port in your curl commands.
Installation
One VSIX works in all supported IDEs:
# VS Code
code --install-extension agent-chat-bridge-*.vsix --force
# Devin
devin-desktop --install-extension agent-chat-bridge-*.vsix --force
Reload the IDE after install (Cmd+Shift+P → Developer: Reload Window).
Compatibility
| IDE |
Extension version |
Tested IDE version |
Notes |
| VS Code |
0.33.0+ |
≥ 1.80.0 |
Session listing requires VS Code ≥ 1.96 (new chatSessions format) |
| Devin |
0.41.0+ |
3.2.19 |
Tested on macOS; CSRF token capture via language server spawn |
| Windsurf |
≤ 0.40.x |
2.x |
Windsurf rebranded to Devin — use 0.41.0+ for the latest |
Agent Self-Registration
When an agent wants to register a callback back to its own session, it needs its own session_id. How it obtains that depends on the IDE.
VS Code (GitHub Copilot)
VS Code injects VSCODE_TARGET_SESSION_LOG as a template variable directly into the agent's system prompt. The path always ends with GitHub.copilot-chat/debug-logs/<SESSION_ID> — the last path segment is the session ID:
# The value looks like this (path varies by OS, last segment is always the session_id):
VSCODE_TARGET_SESSION_LOG=.../GitHub.copilot-chat/debug-logs/97bec20b-caa2-4ffa-b298-29ba81da6bfe
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
session_id
The full path format per OS:
- macOS/Linux:
.../Library/Application Support/Code/User/workspaceStorage/<hash>/GitHub.copilot-chat/debug-logs/<session_id>
- Windows:
...\AppData\Roaming\Code\User\workspaceStorage\<hash>\GitHub.copilot-chat\debug-logs\<session_id>
The agent reads this value from its own system prompt context. echo $VSCODE_TARGET_SESSION_LOG in a terminal returns empty — it is a prompt template variable, not a shell env var.
If VSCODE_TARGET_SESSION_LOG is not available (older VS Code or certain agent configurations), fall back to POST /session/identify as described below.
Devin (Cascade)
Cascade does not inject the session ID into the model's context. POST /session/identify is the only available approach: pass one of the user messages from the current conversation, and the bridge searches all Cascade trajectories to find the matching session.
curl --noproxy localhost -X POST http://localhost:9801/session/identify \
-H 'Content-Type: application/json' \
-d '{
"messages": [{ "text": "<a verbatim user message from this conversation>" }],
"workspace": "/absolute/path/to/project"
}'
The response includes context_messages — surrounding turns — so the agent can verify it matched the right session:
{
"results": [
{
"session_id": "89014dc7-4b6c-4f70-8221-785570448b38",
"matched_messages": [{ "text": "Can you check all the files...", "timestamp": "2026-05-28T00:58:44Z" }],
"context_messages": [
{ "role": "user", "text": "Can you check all the files and tell me if this is good enough or not", "timestamp": "2026-05-28T00:58:44Z" },
{ "role": "model", "text": "Good content overall, but I found several issues...", "timestamp": "2026-05-28T00:58:47Z" },
{ "role": "user", "text": "Validate with actual web research and update if necessary", "timestamp": "2026-05-28T01:00:20Z" }
],
"workspace": "/Users/you/workspace/my-project",
"last_modified": "2026-05-28T01:30:29.566Z"
}
]
}
Use a message that is specific enough to be unique in the conversation — a longer user instruction works better than a short acknowledgement.
No session_id
Omit session_id entirely and the bridge opens a new chat when the trigger fires. Useful when the agent doesn't need the callback to land back in its own session.
Agent Prompt
Use this prompt to instruct any model to register a callback using the bridge. Paste it directly into a system prompt or prepend it to a task. Tweak the details (port, workspace path, trigger type) to match your setup and what you've validated in testing.
You have access to Agent Chat Bridge running at http://localhost:9801.
This lets you register a callback that will send a prompt back to your own chat session
when a trigger fires (timer, shell command finishing, or on-demand webhook).
## Step 1 — Find your session_id
**If you are in VS Code (GitHub Copilot):**
Your session ID is in your system prompt as VSCODE_TARGET_SESSION_LOG.
The path always ends with: .../GitHub.copilot-chat/debug-logs/<SESSION_ID>
The last path segment (after the final / or \) is your session_id.
Example:
VSCODE_TARGET_SESSION_LOG=.../GitHub.copilot-chat/debug-logs/97bec20b-caa2-4ffa-b298-29ba81da6bfe
session_id = 97bec20b-caa2-4ffa-b298-29ba81da6bfe
**If you are in Devin (Cascade) or VSCODE_TARGET_SESSION_LOG is unavailable:**
Call POST /session/identify with a verbatim user message from this conversation.
Pick a message that is specific and unlikely to appear in other sessions.
curl --noproxy localhost -X POST http://localhost:9801/session/identify \
-H 'Content-Type: application/json' \
-d '{
"messages": [{ "text": "<exact text of a user message in this session>" }],
"workspace": "<absolute path to the current workspace>"
}'
Verify the returned context_messages match your conversation before using the session_id.
If multiple results are returned, pick the one whose workspace and context_messages match.
## Step 2 — Register a callback job
Once you have your session_id, register a job:
curl --noproxy localhost -X POST http://localhost:9801/jobs \
-H 'Content-Type: application/json' \
-d '{
"session_id": "<session_id from Step 1>",
"prompt": "<the message to send back to this session when the trigger fires>",
"seconds": <N>
}'
The response contains a job_id. The bridge will send your prompt back to this session
after N seconds. You can then end your turn normally — the callback will resume the session.
## Trigger types
- Timer: add "seconds": N (fires after N seconds; minimum 1)
- Command: add "command": "shell command" (fires when the command exits 0)
- Poll: add "command": "..." and "poll_interval": N (runs every N seconds until exit 0)
- Webhook: omit all trigger fields (sits idle until POST /jobs/<job_id>/fire is called)
## Polling for the reply (optional)
curl --noproxy localhost http://localhost:9801/jobs/<job_id>
Status moves: waiting → fired → delivered → responded
response_text is populated when the model finishes replying.
## Important notes
- Always use --noproxy localhost in IDE terminals (corporate proxies cannot reach localhost).
- Default port is 9801. Confirm with: curl --noproxy localhost http://localhost:9801/health
- seconds: 0 is rejected. Use seconds: 1 to fire near-immediately.
- Omit session_id to open a new chat instead of resuming this one.
Dashboard
Open the dashboard from the IDE command palette (Cmd+Shift+P → Agent Chat Bridge: Open Dashboard), or click the ⚡ Bridge :PORT status bar item at the bottom-right of the IDE window.
You can also open it in any browser while the bridge is running:
open http://localhost:9801/ui
The browser version uses regular fetch against the bridge server; the webview version uses VS Code's postMessage bridge. Both show the same UI.

The status bar item shows the port the bridge is bound to and opens the dashboard on click:

API
The bridge listens on http://localhost:9801 (configurable in IDE Settings → "Agent Chat Bridge").
POST /jobs — register a job
curl --noproxy localhost -X POST http://localhost:9801/jobs \
-H 'Content-Type: application/json' \
-d '{
"session_id": "SESSION_ID",
"prompt": "The task finished. Continue.",
"seconds": 30
}'
Fields:
| Field |
Type |
Required |
Description |
prompt |
string |
✅ |
Message submitted to the session when the trigger fires |
session_id |
string |
— |
Target chat session ID. Omit to open a new chat |
workspace |
string |
— |
Absolute path to workspace. Omit to use the current window's workspace |
seconds |
number |
— |
Fire after N seconds. Must be > 0 (use 1 to fire near-immediately) |
command |
string |
— |
Shell command (command or poll mode — fires on exit 0) |
poll_interval |
number |
— |
Combine with command: run every N seconds until exit 0 |
model |
string or object |
— |
Model to use. String = family name (e.g. "gpt-4o"). Object = { family?, vendor?, id?, version? } |
fallback |
"none" | "new_chat" |
— |
What to do if the session can't be focused. Default: "new_chat" |
Mode is auto-detected from the fields you provide:
| Fields |
Mode |
Behaviour |
seconds (> 0) |
timer |
Fires after N seconds |
command + poll_interval |
poll |
Runs command every N seconds, fires on first exit 0 |
command (no interval) |
command |
Runs command once, fires on exit 0 |
| none of the above |
webhook |
Waits for POST /jobs/:id/fire — fires nothing on its own |
No trigger fields = webhook mode. The job sits idle until you call POST /jobs/:id/fire. Use this when an external system (CI, Teams bot) fires the callback itself.
Response:
{ "ok": true, "job_id": "uuid", "mode": "timer", "fires_in": "30s", "session_id": "...", "fallback": "new_chat", "model": null, "workspace": "/path/to/ws" }
For webhook mode the response includes fire_url instead:
{ "ok": true, "job_id": "uuid", "mode": "webhook", "fire_url": "/jobs/JOB_ID/fire", ... }
GET /jobs/:id — poll job status and read the reply
curl --noproxy localhost http://localhost:9801/jobs/JOB_ID
Poll this endpoint to follow a job through its lifecycle and read the agent's reply once it arrives.
Status progression:
| Status |
Meaning |
waiting |
Trigger hasn't fired yet |
fired |
Message is being sent to the session |
delivered |
Message confirmed delivered; bridge is watching for the agent's reply |
responded |
Agent replied — response_text is populated |
submitted |
VS Code best-effort delivery (no confirmation); no reply watching |
failed |
Delivery failed |
{
"job_id": "uuid",
"session_id": "aaaabbbb-...",
"status": "responded",
"mode": "timer",
"prompt": "Timer fired — I am back.",
"response_text": "I've reviewed the output. The build passed and...",
"model": null,
"fallback": "new_chat",
"started_at": "2026-05-15T21:00:00.000Z",
"running_for": "47s",
"instance": "local"
}
response_text is null while the agent is still running (delivered status) and populated once the model finishes (responded). After responded the job moves to history where response_text is also preserved.
Typical polling loop:
while true; do
RESP=$(curl -s --noproxy localhost http://localhost:9801/jobs/JOB_ID)
STATUS=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))")
case "$STATUS" in
waiting|fired|delivered) sleep 2 ;;
responded) echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('response_text',''))"; break ;;
*) echo "done: $STATUS"; break ;;
esac
done
POST /jobs/:id/fire — manually trigger a job
curl --noproxy localhost -X POST http://localhost:9801/jobs/JOB_ID/fire
Immediately fires any job regardless of trigger type. Useful for testing and external webhooks (CI, GitHub Actions, Teams bots, deployment pipelines, etc.).
DELETE /jobs/:id — cancel a job
curl --noproxy localhost -X DELETE http://localhost:9801/jobs/JOB_ID
GET /jobs — list active jobs
curl --noproxy localhost http://localhost:9801/jobs
Returns both local (this bridge instance) and remote (other bridge instances) jobs:
{
"jobs": [
{
"job_id": "uuid",
"session_id": "...",
"mode": "timer",
"status": "waiting",
"prompt": "...",
"model": null,
"fallback": "new_chat",
"workspace": "/path/to/ws",
"command": null,
"poll_interval": null,
"poll_attempts": null,
"seconds": 30,
"fires_at": "2026-05-15T21:00:00.000Z",
"started_at": "2026-05-15T20:59:30.000Z",
"running_for": "30s",
"instance": "local"
}
],
"count": 1,
"local": 1,
"remote": 0
}
GET /sessions — list all known chat sessions
# All workspaces, most recent first
curl --noproxy localhost 'http://localhost:9801/sessions'
# Filter to a specific workspace
curl --noproxy localhost 'http://localhost:9801/sessions/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4'
# Single session detail
curl --noproxy localhost 'http://localhost:9801/sessions/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/SESSION_ID'
# Paginate
curl --noproxy localhost 'http://localhost:9801/sessions?limit=10&offset=20'
Scans session storage across all workspaces and returns sessions sorted by last activity (most recent first). Sessions with no messages sent are excluded.
Response:
{
"sessions": [
{
"session_id": "aaaabbbb-cccc-dddd-eeee-ffffaaaabbbb",
"workspace_hash": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"workspace_path": "/home/user/workspace/my-project",
"last_modified": "2026-05-08T12:34:56.000Z",
"created_at": "2026-05-08T10:00:00.000Z",
"first_prompt": "Help me build the async callback system",
"title": "Async callback system",
"source": "local"
}
],
"total": 47,
"limit": 20,
"offset": 0
}
Query params: ?limit=N (max 200, default 20), ?offset=N
Get the current window's workspace hash from GET /health → current_workspace_hash.
POST /session/identify — find sessions by message content
Fuzzy-match sessions that contain specific messages. Useful for agents that need to find the session a previous exchange happened in. Works on both IDEs — VS Code searches JSONL chat history, Devin searches Cascade trajectories via the RPC layer.
curl --noproxy localhost -X POST http://localhost:9801/session/identify \
-H 'Content-Type: application/json' \
-d '{
"messages": [{ "text": "Help me build the async callback system" }],
"workspace": "/path/to/project",
"limit": 5
}'
Fields:
| Field |
Type |
Required |
Description |
messages |
array |
✅ |
Messages to search for. Each: { "text": "..." } |
workspace |
string |
— |
Filter to sessions in this workspace path |
limit |
number |
— |
Max results to return (default 10); all sessions are always searched |
Response:
{
"results": [
{
"session_id": "aaaabbbb-...",
"matched_messages": [{ "text": "Help me build the async callback system", "timestamp": null }],
"context_messages": [
{ "role": "user", "text": "Help me build the async callback system", "timestamp": null }
],
"workspace": "/path/to/project",
"last_modified": "2026-05-08T12:34:56.000Z"
}
]
}
GET /health
curl --noproxy localhost http://localhost:9801/health
# → { "status": "ok", "ide": "vscode", "port": 9801, "configured_port": 9801, "jobs": 0,
# "current_workspace": "/path/to/workspace",
# "current_workspace_hash": "a1b2c3d..." }
The ide field is "vscode" or "devin". Extra fields are IDE-specific:
- VS Code:
current_workspace_hash
- Devin:
token, api_key (redacted to "***"), cascade_port, model_uid
GET /registry — see all running bridge instances
curl --noproxy localhost http://localhost:9801/registry
# → {
# "registry": {
# "/path/to/workspace-a": { "port": 9801, "ide": "vscode" },
# "/path/to/workspace-b": { "port": 9802, "ide": "devin" }
# },
# "current_workspace": "/path/to/workspace-a"
# }
Shows all running bridge instances from the shared registry file. Each entry includes the port and the IDE that registered it. Cross-window job forwarding only targets bridges running the same IDE — a VS Code bridge will never forward to a Devin bridge even if they share the same workspace path.
GET /history — completed/cancelled/fired jobs
curl --noproxy localhost http://localhost:9801/history
# → { "history": [ { "job_id": "...", "status": "fired", "prompt": "...", ... } ], "count": 12 }
Persistent history of all jobs that have finished (fired, delivered, responded, cancelled, or failed). Trims to the last 500 entries on disk; in-memory cache serves the last 100. Includes response_text when the agent replied.
GET /events — SSE live updates
curl --noproxy localhost http://localhost:9801/events
Server-sent events stream for real-time updates. Emits jobs, history, and registry events whenever data changes. The dashboard UI subscribes to this to refresh tables without polling.
GET /models — list available models
Returns every chat model the current IDE window can see. Use the family value as the model string shorthand, or the full object for exact targeting.
curl --noproxy localhost http://localhost:9801/models
{
"models": [
{
"id": "...",
"name": "Claude Sonnet 4.5",
"family": "claude-sonnet-4.5",
"vendor": "copilot",
"maxTokens": 128000,
"source": "vscode_lm"
}
],
"total": 12
}
Note: Model availability depends on your subscription and IDE version. Always use GET /models for the current list.
GET /ui — dashboard HTML
Serves the standalone dashboard (same UI used inside the IDE webview). Can be opened in any browser at http://localhost:9801/ui — no IDE needed. See the Dashboard section for screenshots and usage.
POST /config — update a setting
curl --noproxy localhost -X POST http://localhost:9801/config \
-H 'Content-Type: application/json' \
-d '{"key":"port","value":9802}'
Updates a setting (e.g. port) directly from the dashboard or an external script. Requires a window reload for the new value to take effect.
Examples
Timer — simplest test
curl --noproxy localhost -X POST http://localhost:9801/jobs \
-H 'Content-Type: application/json' \
-d '{"session_id":"SESSION_ID","prompt":"Timer fired — I am back.","seconds":10}'
Webhook — fire on demand
# Register — creates a webhook job
RESULT=$(curl -s --noproxy localhost -X POST http://localhost:9801/jobs \
-H 'Content-Type: application/json' \
-d '{"session_id":"SESSION_ID","prompt":"Fired on demand."}')
JOB_ID=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['job_id'])")
# Fire it when ready
curl --noproxy localhost -X POST "http://localhost:9801/jobs/$JOB_ID/fire"
Read the agent's reply
# Register a timer job
JOB_ID=$(curl -s --noproxy localhost -X POST http://localhost:9801/jobs \
-H 'Content-Type: application/json' \
-d '{"session_id":"SESSION_ID","prompt":"Summarise what you just built.","seconds":2}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['job_id'])")
# Poll until responded
while true; do
RESP=$(curl -s --noproxy localhost "http://localhost:9801/jobs/$JOB_ID")
STATUS=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','?'))")
[ "$STATUS" = "responded" ] && echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['response_text'])" && break
[ "$STATUS" = "waiting" ] || [ "$STATUS" = "fired" ] || [ "$STATUS" = "delivered" ] || break
sleep 2
done
No session_id — opens a new chat
curl --noproxy localhost -X POST http://localhost:9801/jobs \
-H 'Content-Type: application/json' \
-d '{"prompt":"Task done. Starting fresh.","seconds":5}'
Cross-workspace — fire into another workspace
curl --noproxy localhost -X POST http://localhost:9801/jobs \
-H 'Content-Type: application/json' \
-d '{
"workspace": "/home/user/workspace/my-api",
"prompt": "Deployment to prod finished. Check the logs.",
"seconds": 1
}'
Poll until a GitHub PR is merged
curl --noproxy localhost -X POST http://localhost:9801/jobs \
-H 'Content-Type: application/json' \
-d '{
"session_id": "SESSION_ID",
"prompt": "PR [#123](https://github.com/sathvikc/agent-chat-bridge/issues/123) merged. Proceed with deployment.",
"command": "gh pr view 123 --repo owner/repo --json merged -q .merged | grep -q true",
"poll_interval": 30
}'
Webhook from CI/CD
# 1. Register — get job_id
JOB_ID=$(curl -s --noproxy localhost -X POST http://localhost:9801/jobs \
-H 'Content-Type: application/json' \
-d '{"session_id":"SESSION_ID","prompt":"CI passed. Review and tag."}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['job_id'])")
# 2. Fire from your pipeline or external system:
curl -X POST http://YOUR_TUNNEL_URL/jobs/$JOB_ID/fire
Configuration
Set in IDE Settings (Cmd+, → search "Agent Chat Bridge"):
| Setting |
Default |
Description |
agentChatBridge.port |
9801 |
HTTP port (requires reload) |
agentChatBridge.host |
"127.0.0.1" |
Interface the HTTP server binds to (requires reload) |
agentChatBridge.commandTimeoutSeconds |
86400 |
Max seconds before a poll/command job is auto-cancelled |
agentChatBridge.fireExpiredOnRestore |
true |
Fire elapsed timer jobs immediately when IDE reopens |
agentChatBridge.debug |
false |
Enable verbose logging in Output → "Agent Chat Bridge" |
Edge Cases
seconds: 0 is rejected
seconds must be strictly > 0. Use seconds: 1 to fire near-immediately. To fire on demand, omit seconds (webhook mode) and call POST /jobs/:id/fire.
Timer expires while IDE is closed
The job persists in jobs.json in the platform state directory (macOS: ~/Library/Application Support/agent-chat-bridge/, Windows: %LOCALAPPDATA%\agent-chat-bridge\, Linux: ~/.local/state/agent-chat-bridge/). When the IDE reopens, remaining time is recalculated. If elapsed and fireExpiredOnRestore is true (default), the prompt fires immediately on startup.
Job targets a different workspace
When workspace doesn't match the current window, the bridge keeps the job in the shared jobs file, spawns the IDE with that workspace path, and fires the prompt when that window's extension restores the job.
Multiple IDE windows open
Each window auto-assigns its own port (9801, 9802 … 9810, first available). A shared registry file (~/.local/state/agent-chat-bridge/ports.json) maps each workspace to its port. When a job's workspace field targets a different window, the bridge looks up its port in the registry and forwards the request directly — no manual port config needed.
If you want a predictable port for a specific workspace (e.g. for CI pipelines), configure it:
{ "agentChatBridge.port": 9802 }
If that port is already taken, the bridge silently auto-assigns the next free one in the range 9801–9810 and logs the new port.
Job Persistence
Jobs survive IDE window reloads (Developer: Reload Window):
| Mode |
Persistence behaviour |
timer |
Resumes with adjusted remaining time; fires immediately if overdue |
poll |
Resumes polling from current state |
webhook |
Restored and continues waiting for /fire |
command |
Not restored — the process is gone after reload |
Jobs are stored in jobs.json in the platform state directory (shared across all IDE windows) and cleared when fired or cancelled. delivered and responded states are transient — they are not persisted and do not survive a window reload.
Troubleshooting
Devin — macOS Permission Prompts
On macOS, the extension may occasionally show a Keychain password dialog:
"devin-desktop" wants to use your confidential information stored in "Devin Safe Storage" in your keychain.
This is a fallback — not the normal path. The extension primarily captures the API key from the Devin language server's stdin metadata when the language server spawns (no Keychain access needed). The captured key is saved to the extension's globalStorage and restored automatically on subsequent IDE restarts.
The Keychain is only accessed when:
- The language server was already running before the bridge extension activated (e.g. first install without a full Devin restart), and
- No key was previously saved in globalStorage.
After the first successful capture, the prompt should not reappear.
The key is never stored outside your local machine. On Windows, the equivalent (DPAPI via PowerShell) runs silently with no prompt. On Linux, safe storage is not supported — the spawn interceptor is the only source; a full Devin restart is required if the key is missing.
If you click "Deny": Cascade RPC calls fail for this session. Reload the window (Cmd+Shift+P → Developer: Reload Window) to re-trigger the prompt, or do a full Devin restart to let the spawn interceptor capture the key without Keychain access.
License
MIT — see LICENSE