feat(event-bridge): wire on-relay agents to inbound webhook events (Slack)#1013
feat(event-bridge): wire on-relay agents to inbound webhook events (Slack)#1013khaliqgant wants to merge 2 commits into
Conversation
Add @agent-relay/event-bridge: connects a long-lived on-relay agent to inbound integration webhook events (Slack first) and relays its replies back out, with no MCP, mount, or credentials on the agent. - Inbound: subscribe to the relay gateway, watch provider VFS paths, and inject each actionable change into a target broker agent. - Outbound: watch the agent's outbox dir and relay reply files through the gateway as relayfile writes, which the provider writeback posts to source. - Provider-agnostic ProviderAdapter contract (Slack implemented; Linear/ Notion follow the same shape) owns paths, writeback format, loop-prevention. - CLI auto-bootstraps gateway URL + scoped token from cloud login (reuses deployed cloud), with a simulate bin for a no-cloud loop test. 11/11 tests pass; build + Prettier clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent-events config endpoint hardcodes a /integrations/** token scope,
which does not authorize the /slack/** paths where Slack files actually live
(confirmed against relayfile VFSRoot and cloud's reply-path derivation).
Bootstrap now resolves the gateway URL from that endpoint but provisions a
correctly-scoped token via POST /api/v1/agents/provision, with scopes derived
from each ProviderAdapter (Slack: relayfile:fs:{read,write}:/slack/**).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR introduces ChangesEvent Bridge Feature
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces the @agent-relay/event-bridge package, which connects long-lived on-relay agents to inbound integration webhook events (starting with Slack) and relays replies back via outbox files. The implementation includes the core bridge logic, a Slack provider adapter, bootstrap utilities, a CLI daemon, and a local simulation harness. Feedback has been provided to optimize network roundtrips by deferring file reads in the bridge, prevent potential infinite loops in the Slack adapter when file reads fail, simplify redundant types and checks in the bootstrap logic, and shorten the changelog entry to comply with the repository's style guide.
| return; | ||
| } | ||
|
|
||
| const file = await readFileSafe(stream, change.path); |
There was a problem hiding this comment.
Eagerly calling readFileSafe before verifying if the event is actionable results in unnecessary network roundtrips to the gateway. For example, when the agent writes a reply, a relayfile.changed event is triggered for the draft reply path. The bridge will eagerly read this file, only for slackProvider.resolveInbound to reject it because it doesn't match MESSAGE_PATH_RE. Similarly, deleted events (where the file no longer exists) or self-authored events (with event.agentId) will also trigger useless read attempts. Checking change.action === 'deleted' and change.agentId beforehand avoids these redundant network calls.
if (change.action === 'deleted' || change.agentId) {
return;
}
const file = await readFileSafe(stream, change.path);| const channelSeg = match[1]; | ||
| const messageSeg = match[2]; | ||
|
|
||
| const msg = asRecord(file?.body); |
There was a problem hiding this comment.
If file is null (e.g., due to a transient read failure), the adapter currently proceeds using fallback titles from the event. However, because msg will be undefined, all loop-prevention checks (such as ignoreBotMessages and ignoreUserIds) are bypassed. This could lead to infinite reply loops if a bot message or our own echoed message fails to be read but is still processed. Safely aborting and returning null if file is null prevents this loop-prevention bypass.
if (!file) {
return null;
}
const msg = asRecord(file.body);|
|
||
| ### Added | ||
|
|
||
| - `@agent-relay/event-bridge` connects a long-lived on-relay agent to inbound integration webhook events: it subscribes to the relay gateway for provider file changes (Slack first), injects each new message into a target broker agent, and relays the agent's outbox reply files back through relayfile writeback to the source — no MCP, mount, or credentials required on the agent. The `agent-relay-event-bridge` daemon auto-bootstraps the gateway URL + scoped token from your cloud login (reusing deployed cloud, nothing to run locally), ships an `agent-relay-event-bridge-simulate` harness for a no-cloud loop test, and exposes a provider-adapter contract for adding Linear, Notion, etc. |
There was a problem hiding this comment.
The changelog entry is very long and detailed. According to the repository style guide, changelog entries should be concise and impact-first, preferring one short bullet per user-visible change. Name the package touched and its practical effect.
| - `@agent-relay/event-bridge` connects a long-lived on-relay agent to inbound integration webhook events: it subscribes to the relay gateway for provider file changes (Slack first), injects each new message into a target broker agent, and relays the agent's outbox reply files back through relayfile writeback to the source — no MCP, mount, or credentials required on the agent. The `agent-relay-event-bridge` daemon auto-bootstraps the gateway URL + scoped token from your cloud login (reusing deployed cloud, nothing to run locally), ships an `agent-relay-event-bridge-simulate` harness for a no-cloud loop test, and exposes a provider-adapter contract for adding Linear, Notion, etc. | |
| - `@agent-relay/event-bridge`: Adds a daemon to connect long-lived on-relay agents to inbound integration webhook events (starting with Slack) and relay replies back via outbox files, with automatic cloud bootstrap and a local simulation harness. |
References
- Changelog entries should be concise and impact-first. Prefer one short bullet per user-visible change. (link)
| ); | ||
| } | ||
|
|
||
| const auth = (await maybeRefresh(stored, refreshAuth)) ?? stored; |
There was a problem hiding this comment.
| async function maybeRefresh( | ||
| auth: StoredAuth, | ||
| refreshAuth: typeof refreshStoredAuth | ||
| ): Promise<StoredAuth | null> { |
There was a problem hiding this comment.
| } | ||
|
|
||
| function joinUrl(base: string, path: string): string { | ||
| return `${base.replace(/\/+$/, '')}${path}`; |
| apiUrl, | ||
| `/api/v1/workspaces/${encodeURIComponent(options.workspace)}/agent-events` | ||
| ); | ||
| const configResponse = await fetchImpl(configUrl, { headers: authHeader }); |
| apiUrl, | ||
| `/api/v1/workspaces/${encodeURIComponent(options.workspace)}/agent-events` | ||
| ); | ||
| const configResponse = await fetchImpl(configUrl, { headers: authHeader }); |
|
|
||
| // 2. Provision a token scoped to the providers' roots (config token is /integrations/** only). | ||
| const provisionUrl = joinUrl(apiUrl, '/api/v1/agents/provision'); | ||
| const provisionResponse = await fetchImpl(provisionUrl, { |
|
I’m blocked by the environment before I can make the PR ready. What I found:
What failed:
I did not mark this READY because the PR still has unresolved findings and I could not apply or post the review from this session. |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
packages/event-bridge/src/bridge.ts (1)
81-81: ⚡ Quick win
pendingcan grow unbounded in a long-lived daemon. Entries are only removed on successful writeback, empty reply, or inject failure. An inbound item the agent never replies to stays forever, so over a long-running processpendingleaks memory (unlikehandled, which is capped viaremember). Consider a TTL/size cap or periodic eviction of stale pending replies.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/event-bridge/src/bridge.ts` at line 81, The pending Map named "pending" (Map<string, PendingReply>) can grow unbounded; add bounded retention and eviction: attach a timestamp/expiry to each PendingReply when inserted, enforce a max size (e.g., MAX_PENDING) and drop oldest entries when exceeded, and add a periodic sweeper (setInterval) that removes entries older than TTL and triggers their failure/cleanup path (same cleanup used for inject failure/empty reply/writeback) so promises/handlers don't leak; update the places that insert into "pending" to set the timestamp/expiry and reuse the existing removal logic for evicted entries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/event-bridge/src/bootstrap.ts`:
- Around line 73-93: In bootstrapGatewayAccess, add request timeouts to both
fetchImpl calls by passing a signal created with AbortSignal.timeout(...);
specifically attach signal: AbortSignal.timeout(timeoutMs) (choose a sensible
timeout or use an existing option if available) to the config fetch (where
configResponse is created from configUrl) and to the provision fetch (where
provisionResponse is created from provisionUrl) so the function won't hang
indefinitely; update the fetch options objects for those calls (the ones that
include headers/authHeader and the POST body) to include the signal and ensure
any existing error handling still handles AbortError.
In `@packages/event-bridge/src/bridge.ts`:
- Around line 159-167: The onReply handler currently catches writeFile errors,
logs them, and returns which lets outbox.archive the file and leaks pending;
change the catch to log the error but then rethrow it so outbox.processFile can
retain/retry the message; ensure pending.delete(replyId) remains only on
successful await stream.writeFile (and do not delete pending in the catch),
referencing onReply, stream.writeFile, pending.delete, log, errMessage,
target.serializeReply, and target.replyPath.
In `@packages/event-bridge/src/simulate.ts`:
- Around line 97-99: The shutdown handler currently calls bridge.stop().then(()
=> process.exit(0)) so rejections are unhandled; update the shutdown logic (the
shutdown function used in process.on('SIGINT'/'SIGTERM')) to handle
bridge.stop() rejections by awaiting or attaching a .catch that logs the error
(or processLogger/error output) and still calls process.exit(0) (or a non-zero
code if you prefer) in both success and failure paths; ensure the handler
returns void and that both SIGINT and SIGTERM listeners use the updated shutdown
function.
---
Nitpick comments:
In `@packages/event-bridge/src/bridge.ts`:
- Line 81: The pending Map named "pending" (Map<string, PendingReply>) can grow
unbounded; add bounded retention and eviction: attach a timestamp/expiry to each
PendingReply when inserted, enforce a max size (e.g., MAX_PENDING) and drop
oldest entries when exceeded, and add a periodic sweeper (setInterval) that
removes entries older than TTL and triggers their failure/cleanup path (same
cleanup used for inject failure/empty reply/writeback) so promises/handlers
don't leak; update the places that insert into "pending" to set the
timestamp/expiry and reuse the existing removal logic for evicted entries.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: ceb61c6a-810d-41ff-9e24-53264339bfc6
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (17)
CHANGELOG.mdpackages/event-bridge/README.mdpackages/event-bridge/package.jsonpackages/event-bridge/src/bootstrap.test.tspackages/event-bridge/src/bootstrap.tspackages/event-bridge/src/bridge.test.tspackages/event-bridge/src/bridge.tspackages/event-bridge/src/cli.tspackages/event-bridge/src/config.tspackages/event-bridge/src/index.tspackages/event-bridge/src/outbox.tspackages/event-bridge/src/providers/index.tspackages/event-bridge/src/providers/slack.test.tspackages/event-bridge/src/providers/slack.tspackages/event-bridge/src/simulate.tspackages/event-bridge/src/types.tspackages/event-bridge/tsconfig.json
| const configResponse = await fetchImpl(configUrl, { headers: authHeader }); | ||
| await assertOk(configResponse, 'config', configUrl); | ||
| const config = (await configResponse.json().catch(() => null)) as { | ||
| workspaceId?: string; | ||
| gatewayUrl?: string; | ||
| } | null; | ||
| if (!config?.gatewayUrl) { | ||
| throw new Error('Gateway bootstrap response missing gatewayUrl'); | ||
| } | ||
| const workspaceId = config.workspaceId ?? options.workspace; | ||
|
|
||
| // 2. Provision a token scoped to the providers' roots (config token is /integrations/** only). | ||
| const provisionUrl = joinUrl(apiUrl, '/api/v1/agents/provision'); | ||
| const provisionResponse = await fetchImpl(provisionUrl, { | ||
| method: 'POST', | ||
| headers: { ...authHeader, 'content-type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| workspaceId, | ||
| agents: [{ name: options.agentName ?? DEFAULT_BRIDGE_AGENT, scopes: options.scopes }], | ||
| }), | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
What Node.js version added AbortSignal.timeout support?
💡 Result:
The static method AbortSignal.timeout was added to Node.js in versions v17.3.0 and v16.14.0 [1]. It provides a way to create an AbortSignal that automatically aborts after a specified number of milliseconds [1][2].
Citations:
- 1: https://nodejs.org/api/globals.html
- 2: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find relevant package.json files
fd -t f "package.json" packages/event-bridge -a > /tmp/pjson_event_bridge.txt || true
cat /tmp/pjson_event_bridge.txt || true
# Also check workspace root engines/node support
if [ -f package.json ]; then
echo "----- root package.json -----"
cat -n package.json | sed -n '1,120p'
fi
# Show packages/event-bridge package.json (first 200 lines)
if [ -s /tmp/pjson_event_bridge.txt ]; then
while read -r f; do
echo "----- $f -----"
cat -n "$f" | sed -n '1,200p'
done < /tmp/pjson_event_bridge.txt
fi
# Search for engines/node in the repo for event-bridge context
rg -n --hidden --no-ignore-vcs "AbortSignal\.timeout|\"engines\"|node\s*[:=]|nodeVersion|engines\.node" packages/event-bridge package.json -S || trueRepository: AgentWorkforce/relay
Length of output: 8363
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show bootstrap.ts around the snippet
BOOTSTRAP="packages/event-bridge/src/bootstrap.ts"
if [ -f "$BOOTSTRAP" ]; then
echo "---- $BOOTSTRAP (lines 1-200) ----"
cat -n "$BOOTSTRAP" | sed -n '1,220p'
fi
# Find fetchImpl definition/usages within event-bridge
rg -n "fetchImpl" packages/event-bridge/src -S
# Find where fetchImpl is imported from (in bootstrap.ts)
rg -n "import .*fetchImpl|fetchImpl\s*=" packages/event-bridge/src/bootstrap.ts -S || true
# Search for fetchImpl in whole repo (to inspect signature)
rg -n "fetchImpl" packages -SRepository: AgentWorkforce/relay
Length of output: 10415
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show bootstrap.test.ts (relevant sections)
BOOT_TEST="packages/event-bridge/src/bootstrap.test.ts"
if [ -f "$BOOT_TEST" ]; then
echo "---- $BOOT_TEST (lines 1-160) ----"
cat -n "$BOOT_TEST" | sed -n '1,200p'
fi
# Search for AbortSignal.timeout usage across repo
rg -n "AbortSignal\.timeout" -S packages || true
rg -n "new AbortController|AbortController" -S packages || true
# Check event-bridge tsconfig for libs/dom types
if [ -f "packages/event-bridge/tsconfig.json" ]; then
echo "---- packages/event-bridge/tsconfig.json (lines 1-220) ----"
cat -n packages/event-bridge/tsconfig.json | sed -n '1,260p'
fi
# Check root tsconfig if needed
if [ -f "tsconfig.json" ]; then
echo "---- tsconfig.json (lines 1-220) ----"
cat -n tsconfig.json | sed -n '1,260p'
fiRepository: AgentWorkforce/relay
Length of output: 8503
Add timeouts to the bootstrap config/provision fetch calls.
bootstrapGatewayAccess awaits both fetchImpl calls without a signal, so if the cloud endpoint stalls the bootstrap can hang indefinitely. Add signal: AbortSignal.timeout(...) (Node runtime here is >=18.0.0, and the repo already uses AbortSignal.timeout elsewhere) to both the config and provision requests.
♻️ Example for the config call
- const configResponse = await fetchImpl(configUrl, { headers: authHeader });
+ const configResponse = await fetchImpl(configUrl, {
+ headers: authHeader,
+ signal: AbortSignal.timeout(15_000),
+ });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/event-bridge/src/bootstrap.ts` around lines 73 - 93, In
bootstrapGatewayAccess, add request timeouts to both fetchImpl calls by passing
a signal created with AbortSignal.timeout(...); specifically attach signal:
AbortSignal.timeout(timeoutMs) (choose a sensible timeout or use an existing
option if available) to the config fetch (where configResponse is created from
configUrl) and to the provision fetch (where provisionResponse is created from
provisionUrl) so the function won't hang indefinitely; update the fetch options
objects for those calls (the ones that include headers/authHeader and the POST
body) to include the signal and ensure any existing error handling still handles
AbortError.
| const { content, contentType } = target.serializeReply(trimmed); | ||
| try { | ||
| await stream.writeFile(target.replyPath, content, { contentType }); | ||
| pending.delete(replyId); | ||
| log('reply posted', { replyId, source: target.source, path: target.replyPath }); | ||
| } catch (err) { | ||
| log('reply writeback failed', { replyId, source: target.source, error: errMessage(err) }); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Writeback failure silently loses the reply. On a failed stream.writeFile, onReply logs and returns normally without rethrowing. Because outbox.processFile archives the file into .sent/ once onReply resolves (it only retains/retries the file when onReply rejects), a transient gateway failure permanently drops the agent's reply and leaks the pending entry. Rethrow so the outbox watcher keeps the file and retries on the next rescan.
🛠️ Proposed fix
const { content, contentType } = target.serializeReply(trimmed);
try {
await stream.writeFile(target.replyPath, content, { contentType });
pending.delete(replyId);
log('reply posted', { replyId, source: target.source, path: target.replyPath });
} catch (err) {
log('reply writeback failed', { replyId, source: target.source, error: errMessage(err) });
+ // Rethrow so the outbox watcher retains the file and retries on the next rescan
+ // instead of archiving it (which would drop the reply permanently).
+ throw err instanceof Error ? err : new Error(errMessage(err));
}Note: this enables retry of transient failures but will retry persistently on permanent failures (e.g. an invalid path); consider a bounded attempt count if that becomes noisy.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { content, contentType } = target.serializeReply(trimmed); | |
| try { | |
| await stream.writeFile(target.replyPath, content, { contentType }); | |
| pending.delete(replyId); | |
| log('reply posted', { replyId, source: target.source, path: target.replyPath }); | |
| } catch (err) { | |
| log('reply writeback failed', { replyId, source: target.source, error: errMessage(err) }); | |
| } | |
| }; | |
| const { content, contentType } = target.serializeReply(trimmed); | |
| try { | |
| await stream.writeFile(target.replyPath, content, { contentType }); | |
| pending.delete(replyId); | |
| log('reply posted', { replyId, source: target.source, path: target.replyPath }); | |
| } catch (err) { | |
| log('reply writeback failed', { replyId, source: target.source, error: errMessage(err) }); | |
| // Rethrow so the outbox watcher retains the file and retries on the next rescan | |
| // instead of archiving it (which would drop the reply permanently). | |
| throw err instanceof Error ? err : new Error(errMessage(err)); | |
| } | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/event-bridge/src/bridge.ts` around lines 159 - 167, The onReply
handler currently catches writeFile errors, logs them, and returns which lets
outbox.archive the file and leaks pending; change the catch to log the error but
then rethrow it so outbox.processFile can retain/retry the message; ensure
pending.delete(replyId) remains only on successful await stream.writeFile (and
do not delete pending in the catch), referencing onReply, stream.writeFile,
pending.delete, log, errMessage, target.serializeReply, and target.replyPath.
| const shutdown = (): void => void bridge.stop().then(() => process.exit(0)); | ||
| process.on('SIGINT', shutdown); | ||
| process.on('SIGTERM', shutdown); |
There was a problem hiding this comment.
Handle bridge.stop() rejection in the shutdown handler.
Unlike the daemon in cli.ts, this only attaches a fulfillment handler. If bridge.stop() rejects, process.exit(0) is never reached and the rejection surfaces as an unhandled promise rejection.
🛡️ Proposed fix
- const shutdown = (): void => void bridge.stop().then(() => process.exit(0));
+ const shutdown = (): void =>
+ void bridge.stop().then(
+ () => process.exit(0),
+ () => process.exit(1)
+ );📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const shutdown = (): void => void bridge.stop().then(() => process.exit(0)); | |
| process.on('SIGINT', shutdown); | |
| process.on('SIGTERM', shutdown); | |
| const shutdown = (): void => | |
| void bridge.stop().then( | |
| () => process.exit(0), | |
| () => process.exit(1) | |
| ); | |
| process.on('SIGINT', shutdown); | |
| process.on('SIGTERM', shutdown); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/event-bridge/src/simulate.ts` around lines 97 - 99, The shutdown
handler currently calls bridge.stop().then(() => process.exit(0)) so rejections
are unhandled; update the shutdown logic (the shutdown function used in
process.on('SIGINT'/'SIGTERM')) to handle bridge.stop() rejections by awaiting
or attaching a .catch that logs the error (or processLogger/error output) and
still calls process.exit(0) (or a non-zero code if you prefer) in both success
and failure paths; ensure the handler returns void and that both SIGINT and
SIGTERM listeners use the updated shutdown function.
There was a problem hiding this comment.
13 issues found across 18 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/event-bridge/src/simulate.ts">
<violation number="1" location="packages/event-bridge/src/simulate.ts:97">
P2: Handle `bridge.stop()` rejection in shutdown so termination is deterministic and does not surface an unhandled promise rejection.</violation>
<violation number="2" location="packages/event-bridge/src/simulate.ts:121">
P2: Missing option values are coerced to `'true'`, which lets `--agent` pass validation and causes the simulator to run against an unintended agent name.</violation>
</file>
<file name="packages/event-bridge/src/cli.ts">
<violation number="1" location="packages/event-bridge/src/cli.ts:52">
P2: Reject partial manual credentials. Mixing a user-supplied gateway URL with a bootstrapped token (or vice versa) can create an invalid credential pair.</violation>
</file>
<file name="packages/event-bridge/src/outbox.ts">
<violation number="1" location="packages/event-bridge/src/outbox.ts:65">
P1: Skip `onReply` for empty/whitespace outbox files so empty replies remain silent per the outbox contract.</violation>
<violation number="2" location="packages/event-bridge/src/outbox.ts:67">
P2: Do not swallow archive failures; silent `rename` errors can cause duplicate redelivery and make failures hard to diagnose.</violation>
</file>
<file name="packages/event-bridge/src/config.ts">
<violation number="1" location="packages/event-bridge/src/config.ts:49">
P2: Normalize provider names from env before storing config to avoid case-sensitive runtime failures for valid provider values.</violation>
<violation number="2" location="packages/event-bridge/src/config.ts:62">
P2: Validate `EVENT_BRIDGE_REPLAY` format (`none`, `last:<n>`, `since:<iso>`) before adding it to config; invalid values currently flow to gateway registration and can fail at runtime.</violation>
</file>
<file name="packages/event-bridge/src/providers/slack.ts">
<violation number="1" location="packages/event-bridge/src/providers/slack.ts:43">
P2: `resolveInbound` also accepts `updated` events, so Slack message edits can retrigger agent nudges. Restrict inbound handling to newly created messages.</violation>
</file>
<file name="packages/event-bridge/src/bridge.ts">
<violation number="1" location="packages/event-bridge/src/bridge.ts:130">
P1: Injection failures are swallowed after the event is marked handled, so transient broker errors can permanently drop inbound messages.</violation>
<violation number="2" location="packages/event-bridge/src/bridge.ts:165">
P1: Writeback failures are swallowed, causing failed reply files to be archived as if they succeeded.</violation>
<violation number="3" location="packages/event-bridge/src/bridge.ts:270">
P2: Reply IDs are truncated to 32-bit entropy, which risks collisions and pending-reply overwrites over time.</violation>
</file>
<file name="packages/event-bridge/package.json">
<violation number="1" location="packages/event-bridge/package.json:21">
P2: Missing `vitest` in `devDependencies` — the package uses vitest in its test scripts but does not explicitly declare it. Every other package in the monorepo that runs vitest lists it as a devDependency. This will cause `npm test` failures if the package is ever installed independently of the workspace.</violation>
</file>
<file name="packages/event-bridge/src/bootstrap.ts">
<violation number="1" location="packages/event-bridge/src/bootstrap.ts:73">
P2: Add a timeout signal to this bootstrap request so the process cannot hang indefinitely on a stalled cloud endpoint.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| try { | ||
| const full = path.join(dir, file); | ||
| const text = await readFile(full, 'utf-8'); | ||
| await options.onReply(replyId, text); |
There was a problem hiding this comment.
P1: Skip onReply for empty/whitespace outbox files so empty replies remain silent per the outbox contract.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/outbox.ts, line 65:
<comment>Skip `onReply` for empty/whitespace outbox files so empty replies remain silent per the outbox contract.</comment>
<file context>
@@ -0,0 +1,130 @@
+ try {
+ const full = path.join(dir, file);
+ const text = await readFile(full, 'utf-8');
+ await options.onReply(replyId, text);
+ // Archive so a later rescan does not re-deliver the same reply.
+ await rename(full, path.join(sentDir, `${Date.now()}-${file}`)).catch(() => {});
</file context>
| pending.delete(replyId); | ||
| log('reply posted', { replyId, source: target.source, path: target.replyPath }); | ||
| } catch (err) { | ||
| log('reply writeback failed', { replyId, source: target.source, error: errMessage(err) }); |
There was a problem hiding this comment.
P1: Writeback failures are swallowed, causing failed reply files to be archived as if they succeeded.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/bridge.ts, line 165:
<comment>Writeback failures are swallowed, causing failed reply files to be archived as if they succeeded.</comment>
<file context>
@@ -0,0 +1,286 @@
+ pending.delete(replyId);
+ log('reply posted', { replyId, source: target.source, path: target.replyPath });
+ } catch (err) {
+ log('reply writeback failed', { replyId, source: target.source, error: errMessage(err) });
+ }
+ };
</file context>
| agent: config.agentName, | ||
| }); | ||
| } catch (err) { | ||
| pending.delete(replyId); |
There was a problem hiding this comment.
P1: Injection failures are swallowed after the event is marked handled, so transient broker errors can permanently drop inbound messages.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/bridge.ts, line 130:
<comment>Injection failures are swallowed after the event is marked handled, so transient broker errors can permanently drop inbound messages.</comment>
<file context>
@@ -0,0 +1,286 @@
+ agent: config.agentName,
+ });
+ } catch (err) {
+ pending.delete(replyId);
+ log('inject failed', { provider: provider.name, replyId, error: errMessage(err) });
+ }
</file context>
| ` which will be printed here as the would-be Slack post.\n` | ||
| ); | ||
|
|
||
| const shutdown = (): void => void bridge.stop().then(() => process.exit(0)); |
There was a problem hiding this comment.
P2: Handle bridge.stop() rejection in shutdown so termination is deterministic and does not surface an unhandled promise rejection.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/simulate.ts, line 97:
<comment>Handle `bridge.stop()` rejection in shutdown so termination is deterministic and does not surface an unhandled promise rejection.</comment>
<file context>
@@ -0,0 +1,131 @@
+ ` which will be printed here as the would-be Slack post.\n`
+ );
+
+ const shutdown = (): void => void bridge.stop().then(() => process.exit(0));
+ process.on('SIGINT', shutdown);
+ process.on('SIGTERM', shutdown);
</file context>
| let gatewayUrl = flags['gateway-url'] ?? env.RELAY_GATEWAY_URL; | ||
| let apiKey = flags['api-key'] ?? env.RELAY_API_KEY; | ||
|
|
||
| if (!gatewayUrl || !apiKey) { |
There was a problem hiding this comment.
P2: Reject partial manual credentials. Mixing a user-supplied gateway URL with a bootstrapped token (or vice versa) can create an invalid credential pair.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/cli.ts, line 52:
<comment>Reject partial manual credentials. Mixing a user-supplied gateway URL with a bootstrapped token (or vice versa) can create an invalid credential pair.</comment>
<file context>
@@ -0,0 +1,138 @@
+ let gatewayUrl = flags['gateway-url'] ?? env.RELAY_GATEWAY_URL;
+ let apiKey = flags['api-key'] ?? env.RELAY_API_KEY;
+
+ if (!gatewayUrl || !apiKey) {
+ if (flags.bootstrap === 'false') {
+ console.error('Missing --gateway-url/--api-key and --no-bootstrap was set.');
</file context>
| throw new Error('EVENT_BRIDGE_AGENT (target on-relay agent name) is required'); | ||
| } | ||
|
|
||
| const providers = csv(env.EVENT_BRIDGE_PROVIDERS) ?? ['slack']; |
There was a problem hiding this comment.
P2: Normalize provider names from env before storing config to avoid case-sensitive runtime failures for valid provider values.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/config.ts, line 49:
<comment>Normalize provider names from env before storing config to avoid case-sensitive runtime failures for valid provider values.</comment>
<file context>
@@ -0,0 +1,77 @@
+ throw new Error('EVENT_BRIDGE_AGENT (target on-relay agent name) is required');
+ }
+
+ const providers = csv(env.EVENT_BRIDGE_PROVIDERS) ?? ['slack'];
+ const injectMode = trim(env.EVENT_BRIDGE_INJECT_MODE) === 'steer' ? 'steer' : 'wait';
+
</file context>
| file: WorkspaceFileLike | null, | ||
| ctx: InboundContext | ||
| ): InboundItem | null { | ||
| if (event.action === 'deleted') { |
There was a problem hiding this comment.
P2: resolveInbound also accepts updated events, so Slack message edits can retrigger agent nudges. Restrict inbound handling to newly created messages.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/providers/slack.ts, line 43:
<comment>`resolveInbound` also accepts `updated` events, so Slack message edits can retrigger agent nudges. Restrict inbound handling to newly created messages.</comment>
<file context>
@@ -0,0 +1,115 @@
+ file: WorkspaceFileLike | null,
+ ctx: InboundContext
+ ): InboundItem | null {
+ if (event.action === 'deleted') {
+ return null;
+ }
</file context>
| } | ||
|
|
||
| function mintReplyId(): string { | ||
| return `r-${globalThis.crypto.randomUUID().slice(0, 8)}`; |
There was a problem hiding this comment.
P2: Reply IDs are truncated to 32-bit entropy, which risks collisions and pending-reply overwrites over time.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/bridge.ts, line 270:
<comment>Reply IDs are truncated to 32-bit entropy, which risks collisions and pending-reply overwrites over time.</comment>
<file context>
@@ -0,0 +1,286 @@
+}
+
+function mintReplyId(): string {
+ return `r-${globalThis.crypto.randomUUID().slice(0, 8)}`;
+}
+
</file context>
| "scripts": { | ||
| "build": "tsc", | ||
| "clean": "rm -rf dist", | ||
| "test": "vitest run", |
There was a problem hiding this comment.
P2: Missing vitest in devDependencies — the package uses vitest in its test scripts but does not explicitly declare it. Every other package in the monorepo that runs vitest lists it as a devDependency. This will cause npm test failures if the package is ever installed independently of the workspace.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/package.json, line 21:
<comment>Missing `vitest` in `devDependencies` — the package uses vitest in its test scripts but does not explicitly declare it. Every other package in the monorepo that runs vitest lists it as a devDependency. This will cause `npm test` failures if the package is ever installed independently of the workspace.</comment>
<file context>
@@ -0,0 +1,46 @@
+ "scripts": {
+ "build": "tsc",
+ "clean": "rm -rf dist",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
</file context>
| apiUrl, | ||
| `/api/v1/workspaces/${encodeURIComponent(options.workspace)}/agent-events` | ||
| ); | ||
| const configResponse = await fetchImpl(configUrl, { headers: authHeader }); |
There was a problem hiding this comment.
P2: Add a timeout signal to this bootstrap request so the process cannot hang indefinitely on a stalled cloud endpoint.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/event-bridge/src/bootstrap.ts, line 73:
<comment>Add a timeout signal to this bootstrap request so the process cannot hang indefinitely on a stalled cloud endpoint.</comment>
<file context>
@@ -0,0 +1,136 @@
+ apiUrl,
+ `/api/v1/workspaces/${encodeURIComponent(options.workspace)}/agent-events`
+ );
+ const configResponse = await fetchImpl(configUrl, { headers: authHeader });
+ await assertOk(configResponse, 'config', configUrl);
+ const config = (await configResponse.json().catch(() => null)) as {
</file context>
Summary
Adds
@agent-relay/event-bridge— connects a long-lived on-relay agent to inbound integration webhook events (Slack first) and relays its replies back out, with no MCP, mount, or credentials on the agent. This is the single-agent Slack happy path toward controlling a team of relay agents from Slack.How it works
@agent-relay/events), registers provider watch globs (/slack/channels/**), and for each actionablerelayfile.changedinjects a nudge into the target broker agent viasendMessage../outbox/<replyId>.mdwith its native file-write tool; the bridge relays that file through the gateway to the provider's writeback path (/slack/channels/<id>/messages/<ts>/replies/<draft>.json), which cloud's Slack writeback posts in-thread.ProviderAdapter. Slack is implemented; Linear/Notion follow the same contract.GET /api/v1/workspaces/<ws>/agent-events) and provisions a/slack/**-scoped token (POST /api/v1/agents/provision) from your cloud login — nothing runs locally.--gateway-url/--api-keyskip it.Run
Local loop test (no cloud/Slack):
node packages/event-bridge/dist/simulate.js --agent lead --text "deploy staging".Follow-up: replace the outbox with an MCP writeback primitive
The
./outboxfile relay is an interim outbound mechanism. The plan is to add a primitive MCP that exposes tools letting the agent run a script which calls the relayfile SDK directly for the writeback logic. Once that lands, the agent posts replies via the MCP tool (script → relayfile SDK) and the bridge no longer needs the outbox (or its gatewaywriteFilerelay) — outbound becomes a first-class tool call instead of a watched file. The inbound half (gateway watch → inject) is unaffected.Notes / validation
relayfile(VFSRoot: /slack) andcloud(identical reply-path derivation).chat.postMessagefrom the relayfile writeback in a real Slack-connected workspace.CHANGELOG.mdupdated.🤖 Generated with Claude Code