fix(opencode-plugin): use @relaycast/sdk instead of dead bespoke RPC API#1190
Conversation
The OpenCode Relay plugin called a bespoke RPC API at https://www.relaycast.dev/api/v1 (/register, /dm/send, /inbox/check, /agent/list, /message/post, /agent/add, /agent/remove). That host is dead (HTTP 522). This rewrites the transport to the published @relaycast/sdk pointed at the current engine (https://cast.agentrelay.com), preserving the plugin's public contract (tool names, args, return shapes, idle-poll hook). Endpoint -> SDK mapping: - /register -> RelayCast({apiKey: workspace}).registerOrRotate({name}) then relay.as(token) for the agent-scoped client - /dm/send -> AgentClient.dm(to, text) - /inbox/check -> AgentClient.inbox(); flattened via inboxToMessages() (engine inbox has no `messages[]`; mentions[] + unread_dms[] are the message-like items) - /agent/list -> RelayCast.agents.list() - /message/post -> AgentClient.post(channel, text) - /agent/add -> RelayCast.agents.registerOrGet({name, metadata:{cli,task}}) (agents.spawn requires a cli enum that excludes "opencode", so we register the identity and spawn the local process) - /agent/remove -> RelayCast.agents.delete(name) - Default base URL is now https://cast.agentrelay.com; still configurable via RelayState.apiBaseUrl, routed into the SDK as baseUrl. - Removed the bespoke fetch/normalizeBaseUrl plumbing. - Tests now mock the SDK (RelayCast/AgentClient) instead of the dead HTTP URLs. - Added a standalone vitest.config.ts so the plugin tests run in isolation (the plugin is not part of the monorepo npm workspaces). Build (tsc) and tests (vitest, 19 passing) both green against @relaycast/sdk@4.1.6. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
🚧 Files skipped from review as they are similar to previous changes (4)
📝 WalkthroughWalkthroughThe ChangesSDK Migration for opencode-relay-plugin
Sequence Diagram(s)sequenceDiagram
participant Tool as relay_connect Tool
participant Factory as RelayCastFactory
participant RC as RelayCast
participant AC as AgentClient
participant RS as RelayState
Tool->>Factory: createRelayCast(apiKey)
Factory-->>Tool: RelayCast instance
Tool->>RC: registerOrRotate(agentName)
RC-->>Tool: token or RelayError
alt isAuthError(error)
Tool-->>Tool: return invalid workspace key error
end
Tool->>RC: as(agentName)
RC-->>Tool: AgentClient
Tool->>RS: relay=RC, agent=AC, token, workspace, agentName, connected=true
Note over Tool,RS: Subsequent tool calls use state.relay and state.agent
Tool->>AC: agent.dm(to, text)
Tool->>AC: agent.inbox()
Tool->>RC: relay.agents.list()
Tool->>AC: agent.post(channel, text)
Tool->>RC: relay.agents.registerOrGet(name, metadata)
Tool->>RC: relay.agents.delete(name)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
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)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed: one or more packages not found in the registry. 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 refactors the opencode-relay-plugin to use the @relaycast/sdk instead of making direct HTTP fetch calls, updating the plugin state, hooks, tools, and tests accordingly. Feedback on these changes suggests adding defensive checks in inboxToMessages to handle potentially missing or nullish inbox properties, and safely accessing the token from the registration response in relay_connect to prevent runtime errors.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| export function inboxToMessages(inbox: InboxResponse): RelayMessage[] { | ||
| const messages: RelayMessage[] = []; | ||
|
|
||
| for (const mention of inbox.mentions) { | ||
| messages.push({ | ||
| id: mention.id, | ||
| from: mention.agentName, | ||
| text: mention.text, | ||
| channel: mention.channelName, | ||
| ts: mention.createdAt, | ||
| }); | ||
| } | ||
|
|
||
| if (!res.ok) { | ||
| throw new Error(`Relay API error: ${res.status} ${await res.text()}`); | ||
| for (const dm of inbox.unreadDms) { | ||
| if (!dm.lastMessage) { | ||
| continue; | ||
| } | ||
| messages.push({ | ||
| id: dm.lastMessage.id, | ||
| from: dm.from, | ||
| text: dm.lastMessage.text, | ||
| ts: dm.lastMessage.createdAt, | ||
| }); | ||
| } | ||
|
|
||
| return (await res.json()) as RelayAPIResponse; | ||
| return messages; | ||
| } |
There was a problem hiding this comment.
To prevent potential runtime errors, it is highly recommended to defensively handle cases where inbox is null/undefined, or where mentions and unreadDms are missing or not arrays. Using optional chaining and nullish coalescing ensures the plugin remains robust against unexpected API or SDK responses.
export function inboxToMessages(inbox: InboxResponse): RelayMessage[] {
const messages: RelayMessage[] = [];
if (!inbox) {
return messages;
}
for (const mention of inbox.mentions ?? []) {
messages.push({
id: mention.id,
from: mention.agentName,
text: mention.text,
channel: mention.channelName,
ts: mention.createdAt,
});
}
for (const dm of inbox.unreadDms ?? []) {
if (!dm.lastMessage) {
continue;
}
messages.push({
id: dm.lastMessage.id,
from: dm.from,
text: dm.lastMessage.text,
ts: dm.lastMessage.createdAt,
});
}
return messages;
}There was a problem hiding this comment.
Fixed in 52a2e89. inboxToMessages now accepts InboxResponse | null | undefined and guards both arrays with Array.isArray(inbox?.mentions) ? ... : [] (same for unreadDms), returning [] rather than throwing on a null/partial payload during an idle poll. The same defensive guards are applied in the new collectInboxMessages. Covered by the new inboxToMessages defensive handling tests (null/undefined inbox and non-array fields).
| const registration = await relay.registerOrRotate({ name }); | ||
| token = registration.token; |
There was a problem hiding this comment.
Defensively handle cases where registration might be null or undefined to avoid throwing a TypeError when accessing .token.
| const registration = await relay.registerOrRotate({ name }); | |
| token = registration.token; | |
| const registration = await relay.registerOrRotate({ name }); | |
| token = registration?.token; |
There was a problem hiding this comment.
Fixed in 52a2e89. Now uses registration?.token (and token is typed string | undefined), so a null/undefined registration response no longer throws a TypeError on property access — it falls through to the existing typeof token !== "string" || token.length === 0 guard which raises the clear register response missing token error.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c5f693b575
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const inbox = await state.agent.inbox(); | ||
| const messages = inboxToMessages(inbox); |
There was a problem hiding this comment.
Mark surfaced inbox messages as read
When there is any unread DM or mention, this call leaves it unread; the SDK inbox endpoint is read-only and the SDK exposes markRead separately, so relay_inbox and the session.idle poll will keep returning/injecting the same message every poll instead of matching the old /inbox/check drain behavior. Mark the surfaced message ids read, or maintain an equivalent seen watermark, after surfacing them.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 52a2e89. Surfaced messages are now drained: collectInboxMessages calls agent.markRead(id) (the SDK read-receipt API) for each surfaced mention/DM/channel message, and also records the id in a persistent state.seenMessageIds watermark as a backstop so a message is never re-injected even if the markRead write lags or fails. Both relay_inbox and the session.idle poll go through this path, so the read-only engine inbox no longer re-surfaces the same items each poll. Covered by new tests drains surfaced inbox messages via markRead... (tools) and idle-drains-and-does-not-reinject (polling).
| for (const dm of inbox.unreadDms) { | ||
| if (!dm.lastMessage) { | ||
| continue; | ||
| } | ||
| messages.push({ | ||
| id: dm.lastMessage.id, |
There was a problem hiding this comment.
Fetch all unread DM messages before flattening
When a DM conversation has unreadCount > 1, the SDK inbox summary only includes lastMessage, and this loop emits just that one message. Earlier unread DMs from the same sender are never returned by relay_inbox or idle injection despite the old flat messages[] behavior, so multi-message instructions can be silently lost; fetch the conversation's unread messages or otherwise preserve the missing messages before flattening.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 52a2e89. For DM conversations with unreadCount > 1, collectInboxMessages now hydrates the unread tail via agent.dms.messages(conversationId, { limit }) (capped at 50) and restores chronological order, instead of only taking lastMessage. If the fetch fails or returns nothing it falls back to the summary lastMessage. Covered by the new test hydrates earlier messages of a multi-message DM conversation.
| for (const mention of inbox.mentions) { | ||
| messages.push({ | ||
| id: mention.id, | ||
| from: mention.agentName, | ||
| text: mention.text, | ||
| channel: mention.channelName, | ||
| ts: mention.createdAt, |
There was a problem hiding this comment.
Surface unread channel posts, not just mentions
For channel posts that do not explicitly mention the agent, the SDK inbox records them under unreadChannels, but this flattening path only returns mentions and DMs. That means messages sent with relay_post will not appear in another agent's relay_inbox or idle prompt unless they contain a mention, regressing the previous channel-message queue behavior; include unread channel summaries or fetch the unread channel messages before returning an empty inbox.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 52a2e89. collectInboxMessages now iterates inbox.unreadChannels and, for each channel with unread posts, fetches them via agent.messages(channelName, { limit }) (capped at 50) and surfaces them with their channel set. This restores the previous behavior where non-mention relay_post messages reach another agents inbox/idle injection. Deduped against mentions and drained via the same markRead+ watermark path. Covered by the new testsurfaces unread channel posts that did not mention the reader`.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@plugins/opencode-relay-plugin/tests/mock-relay-server.ts`:
- Around line 15-20: The QueuedDm interface is missing the channel property,
which causes the filter check at line 50 that uses 'channel' in m to always fail
for queued items. Add a channel property to the QueuedDm interface to match the
structure of messages that need to be filtered for mentions, and ensure the
injectMessage function preserves the channel field when creating queued items
instead of dropping it. This will allow mention payloads to be properly
identified and tested through the shared mock helper.
🪄 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: b80a5815-6b6a-4a78-a666-0f266096134a
📒 Files selected for processing (10)
plugins/opencode-relay-plugin/package.jsonplugins/opencode-relay-plugin/src/hooks.tsplugins/opencode-relay-plugin/src/index.tsplugins/opencode-relay-plugin/src/tools.tsplugins/opencode-relay-plugin/tests/integration.test.tsplugins/opencode-relay-plugin/tests/mock-relay-server.tsplugins/opencode-relay-plugin/tests/polling.test.tsplugins/opencode-relay-plugin/tests/spawn.test.tsplugins/opencode-relay-plugin/tests/tools.test.tsplugins/opencode-relay-plugin/vitest.config.ts
…essage DMs/channel posts The engine `GET /v1/inbox` is read-only and returns a summary, unlike the old `/inbox/check` which drained a queue. Faithfully replicate the drain behavior: - Drain surfaced messages via `markRead` and a local `seenMessageIds` watermark, so `relay_inbox` and the `session.idle` poll no longer re-surface the same messages every poll. (Codex P1) - Hydrate multi-message DM conversations (`unreadCount > 1`) from `dms.messages` so earlier unread DMs are not dropped by the summary's `lastMessage`-only view. (Codex P2) - Surface unread channel posts (under `unreadChannels`) that did not mention the reader by fetching them via `messages(channel)`, preserving the old channel-message queue behavior. (Codex P2) - Add defensive null/array guards in `inboxToMessages` and on the registration token. (Gemini) New shared `collectInboxMessages(agent, inbox, seen)` performs the hydrate + drain and is used by both the inbox tool and the idle hook. The mock relay server now models a read-only inbox (acked via `markRead`) plus the DM/channel message APIs; tests cover drain, multi-message DMs, channel posts, and defensive nulls. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@plugins/opencode-relay-plugin/src/tools.ts`:
- Around line 278-302: The channel unread count is not being decremented by the
markRead method, causing the same channels to be re-fetched on every idle poll
despite deduplication by the seen watermark. Add a mechanism to track which
channels have already been processed in the current poll cycle. Before calling
fetchUnreadChannelMessages for each channel in the unreadChannels loop, check if
the channel has already been hydrated in this poll using a separate tracking
structure (such as a Set to store processed channel names). Only call
fetchUnreadChannelMessages if the channel is not in the processed set, then add
it to the set after successful hydration. This gates channel re-fetch on whether
it has already been processed in the current cycle, eliminating redundant
network round-trips for the same channels.
🪄 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: ba7802cd-a35f-4716-b108-5f6e41aadc3a
📒 Files selected for processing (6)
plugins/opencode-relay-plugin/src/hooks.tsplugins/opencode-relay-plugin/src/index.tsplugins/opencode-relay-plugin/src/tools.tsplugins/opencode-relay-plugin/tests/mock-relay-server.tsplugins/opencode-relay-plugin/tests/polling.test.tsplugins/opencode-relay-plugin/tests/tools.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
- plugins/opencode-relay-plugin/src/hooks.ts
- plugins/opencode-relay-plugin/src/index.ts
- plugins/opencode-relay-plugin/tests/tools.test.ts
- plugins/opencode-relay-plugin/tests/polling.test.ts
- plugins/opencode-relay-plugin/tests/mock-relay-server.ts
…watermark CodeRabbit correctly flagged that @relaycast/sdk's markRead is a no-op stub for delivery state (it posts a read receipt to /v1/messages/:id/read and does not change unread/delivery state). The inbox "drain" therefore relied entirely on the client-side seenMessageIds watermark and the markRead calls were dead/misleading code. The SDK does expose a real server-side drain: the durable delivery ledger (deliveries() + ackDelivery()/failDelivery()/deferDelivery() over /v1/deliveries). Each DeliveryItem carries the underlying messageId, so surfaced inbox items map cleanly onto delivery rows. - Replace the no-op markRead drain with ackSurfacedDeliveries(): list the non-terminal delivery queue (capped at MAX_DELIVERY_SCAN), match rows to the just-surfaced message ids, and ackDelivery() each. This transitions the delivery to `acked` server-side so it stops being replayed across restarts (best-effort; the watermark remains the in-session backstop). - Bound seenMessageIds with FIFO eviction via rememberSeen() (MAX_SEEN_MESSAGE_IDS = 2000) so it can't grow unboundedly over a long session; older ids are safe to drop since their deliveries are acked. - Update mock-relay-server to model the delivery ledger (deliveries/ack) instead of markRead, and update tests accordingly; add rememberSeen bounding tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Problem
The OpenCode Relay plugin (
plugins/opencode-relay-plugin) talked to a bespoke RPC API athttps://www.relaycast.dev/api/v1(/register,/dm/send,/inbox/check,/agent/list,/message/post,/agent/add,/agent/remove). That host is dead (returns HTTP 522), so the plugin is fully broken.Change
Transport swap only — same tool names, arguments, return shapes, and idle inbox-polling behavior. The plugin now uses the published
@relaycast/sdk(^4.1.6) pointed at the current engine.https://cast.agentrelay.com(oldwwwhost is dead; no data continuity). Still configurable via the existingRelayState.apiBaseUrl, now routed into the SDK asbaseUrl.fetch/normalizeBaseUrlplumbing.RelayCast/AgentClient) instead of the dead HTTP URLs, with equivalent coverage (19 tests).Endpoint → SDK mapping
/registernew RelayCast({ apiKey: workspace, baseUrl }).registerOrRotate({ name })→ thenrelay.as(token)for the agent client/dm/sendAgentClient.dm(to, text)/inbox/checkAgentClient.inbox()flattened byinboxToMessages()/agent/listRelayCast.agents.list()/message/postAgentClient.post(channel, text)/agent/addRelayCast.agents.registerOrGet({ name, metadata: { cli, task } })/agent/removeRelayCast.agents.delete(name)Things that didn't map cleanly
/v1/inboxreturns{ unread_channels, mentions, unread_dms, recent_reactions }— nomessages[].inboxToMessages()flattens channelmentions[]andunread_dms[](using each conversation'slastMessage) into the flatRelayMessage[]the inbox tool and idle hook expect.unread_channelsandrecent_reactionsare not message-like and are ignored./agent/add(worker registration). The SDK'sagents.spawnrequires aclienum (claude|codex|gemini|aider|goose) that excludesopencode, and it is meant to spawn a remote process. The plugin instead spawns a local OpenCode process, so the relay-side step is just registering the worker identity — implemented withagents.registerOrGet({ name, metadata: { cli: 'opencode', task } }). The localspawn(...)behavior, bootstrap prompt, and env vars are unchanged.relay_agentsreturn type. Previously passed through whatever the bespoke API returned; now returns the SDK'sAgent[]objects. Shape ({ agents: [...] }) is preserved.Build / test
npm run build(tsc): passesnpm test(vitest): 19/19 passing@relaycast/sdk@4.1.6.A standalone
vitest.config.tswas added to the plugin so its tests run in isolation (the plugin is not part of the monorepo npm workspaces; without it vitest climbs to the root config which can't resolve in a fresh plugin install).🤖 Generated with Claude Code