From 5cc9a59821eebb3e1f0a1d05ef0162128bc9d8f4 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:02:36 +0530 Subject: [PATCH] Agents: one skill page for Spaces, pointed at every turn, and binary files that say where their bytes are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On 2026-10-06 Hermes was asked to read a PNG in its space and answered that it could not. Its key could download the file from GET /v1/spaces/:spaceId/blobs/:hash, but nothing told it so: read_asset's description said the bytes were "not readable over this face", and the rowboat-spaces skill covered only files in messages. - The rowboat-spaces skill is now the one page an agent needs: its setup (the MCP server, its key in the environment, and to say which is missing), the mention rules, what the tools let it do without their schemas, and how files download, binary ones included. - The standing paragraph of the coding-agent connectors tells the agent to load the skill once per session, or to read it at its address on GitHub, so an agent that cannot install skills reads the same page. The mention rules stay in the paragraph, since a skipped skill would break hand-offs silently. - read_asset's description says where a binary file's bytes download, and on the agent face its result adds a second text block with the full address. The structured result is unchanged, so both faces return the same object and the app's own tools see no difference. SPEC §8 and CONTRACT.md are amended to match. The Hermes plugin points its platform hint at the same page in its own repository. Co-Authored-By: Claude Opus 5.5 --- apps/harbor/CONTRACT.md | 1 + apps/harbor/SPEC.md | 4 +- apps/harbor/packages/protocol/src/mcp.ts | 4 +- .../server/src/connectors/thread-prompt.ts | 10 ++++- apps/harbor/packages/server/src/mcp.ts | 24 ++++++++++-- apps/harbor/packages/server/test/mcp.test.ts | 27 +++++++++++++ skills/rowboat-spaces/SKILL.md | 39 ++++++++++++++----- 7 files changed, 90 insertions(+), 19 deletions(-) diff --git a/apps/harbor/CONTRACT.md b/apps/harbor/CONTRACT.md index 65dd153b1..9bce60918 100644 --- a/apps/harbor/CONTRACT.md +++ b/apps/harbor/CONTRACT.md @@ -34,6 +34,7 @@ How the server is built — modules, invariants, the slice a capability cuts thr - **Jev, the built-in agent** (`BUILT_IN_CONNECTION`, `JEV_KIND`, migration 033 — 2026-10-07, [spec §8](./SPEC.md#8-agents-in-spaces) Jev). With `HARBOR_JEV_OPENROUTER_KEY` set, every org has exactly one member with `kind: agent`, `agentKind: jev`, `agentConnection: builtin`, `displayName: Ro` (people meet it as Ro; Jev is the model it runs on), and no `ownerId`. It appears in `listOrgMembers` and can be added with `addMembers` like any agent. It is not in `AGENT_PAIRS`, so `createAgent` refuses it (`invalid_request`), and as an ownerless agent it takes no keys and no credential. In a shared space it is a member of, after each message it did not write, it may post a reply in that message's thread whose body is only mention tokens, `cc [@Name](#member:id) …` (at most three). That reply invokes the agents it mentions like any message does, at the hand-off depth of the message it answers, not a fresh depth for Jev; migration 033 (`message_hops`) records that depth when an agent posts. A mention of Jev in a space settles `done`; a message in a DM with it settles `failed`, with an `error` saying it does not chat. Older clients draw it as a generic agent. - **Agent invocations** (`Invocation`, `InvocationUpdate`, `ConnectorCapabilities` in `invocation.ts`; the `invocation`, `invocation_stop` and `invocation_state` frames; migration 027 — 2026-09-30, [spec §8](./SPEC.md#8-agents-in-spaces) Invoking agent members). A posted message that mentions an agent member (not its author) invokes it, and so does every message a person posts in a direct space with an agent member, mention or not (2026-09-30), decided in the message's own transaction. `postMessage` answers with `invocations` for every agent it invoked, or was refused to: `refused` with `not_permitted` when the invoker shares no shared space with the agent (a DM alone does not count), or `hop_limit` when an agent's post would be the fourth hand-off (depth = 1 + the deepest live invocation of the posting agent; a person's mention is 0). The `post_message` tool returns the same as `invocations: [{agentId, state, refusal?}]`. `NewMessage.agentOptions` (keyed by agent id) lands on that agent's invocation uninterpreted; entries for agents the message does not invoke are dropped. Edits never invoke. Per (agent, conversation), where the conversation is the thread root, one invocation is live at a time: a new one is `pending` when the conversation is free, `pending` with `answers` when the live one is `waiting`, and otherwise `queued`, promoted to `pending` when the live one ends, in the order the triggering messages were posted (their stream offsets, assigned under the space lock, so the order holds across Harbor instances whatever their clocks say). Every state change runs in a transaction holding the space's Postgres advisory lock, so the queue is correct with any number of Harbor instances on one database. A `working` invocation that has reported nothing for 30 minutes is marked `failed` the next time its conversation needs the turn; `pending` and `waiting` never time out. **The connector's routes take an agent key and act only on that agent's own invocations** (a person is `forbidden`, another agent's invocation `not_found`): `GET /v1/agent/invocations` lists the pending, working and waiting ones for a reconnecting connector; `POST /v1/agent/invocations/:invocationId/ack` moves pending to working (idempotent; a cancelled one answers `invalid_request`, drop it); `POST /v1/agent/invocations/:invocationId/update` takes an `InvocationUpdate` (`working` as a heartbeat with an optional `activity` and `link`, `waiting` with the `activity` it needs, `done`, `failed` with an `error`, `cancelled`), refused once the invocation is finished; `POST /v1/agent/capabilities` declares `ConnectorCapabilities` (`stop`, up to 8 `options`), again whenever they change. **Option defaults** (2026-10-01, migration 030): `PUT /v1/agents/:agentId/option-defaults` with `{defaults}` sets the defaults for the options the agent's connector declared, its owner only, app only; each key a declared option and each value a declared choice's id or a toggle's boolean (`invalid_request` otherwise); it replaces them all, and `{}` clears them. `GET /v1/agents/:agentId/capabilities` returns them as `defaults` beside `capabilities`. A new invocation's `options` are the defaults that still fit the declared options, overlaid by the invoker's `agentOptions` for that agent, whoever invoked it. An agent's connections get `invocation` when one becomes pending (new, or out of the queue) and `invocation_stop` when someone stops a running one; both are ephemeral. A connector lists its invocations when it connects and again every minute: the frame is the fast path, the list the guarantee, including while live delivery is per instance (the hub is in-process until it gets a shared bus). **People's side:** `GET /v1/agents/:agentId/capabilities` (any org member: the composer's options, whether Stop is offered); `GET /v1/spaces/:spaceId/invocations?threadRootId=` (readable space, newest first, at most 100; the `get_invocations` tool); `POST /v1/invocations/:invocationId/cancel` (the `stop_invocation` tool): a queued or pending one is cancelled at once by its invoker only, and a working or waiting one is stopped by its invoker or an admin only when the connector declared `stop` (`stopRequested` is set, the agent gets `invocation_stop`, and the connector reports `cancelled`); a finished one comes back unchanged. Every change of state reaches the space's subscribers as an ephemeral `invocation_state` frame. Older clients ignore the new frames and fields. - **Agent approvals** (`Approval`, `ApprovalRequest`, `ApprovalDecision`, `ApprovalClose`, `APPROVAL_CHOICE_LABELS` in `approval.ts`; `Message.approval`; the `approval` space event and the `approval_decided` frame; migration 029 — 2026-10-01, [spec §8](./SPEC.md#8-agents-in-spaces) part 4). An approval is its own record on an invocation, riding on the agent's card message as a poll rides on its message: the message row keeps the approval as raised, reads fold in the current one, and every change after raising is an `approval` event carrying the whole approval (folding clients replace the card's `approval`). Choices are `allow_once`, `allow_session` (shown as "Allow in this thread"), `allow_always` and `deny`; states are `open`, then `allowed`, `denied`, `expired` or `cancelled`. **The connector's routes take an agent key, about that agent's own approvals:** `POST /v1/agent/invocations/:invocationId/approvals` raises one on a working or waiting invocation with `{requestKey, title, detail, reason?, choices}` and answers `{approval, message}`: it posts the agent's card as a reply in the invocation's thread (its body a text rendering: the title, the detail in a code block, the reason, the choices) in the same transaction, and the invocation becomes `waiting` with the activity `Waiting for approval: `; raising the same `requestKey` again returns the first. While any of its approvals is open the invocation stays `waiting` through a `working` heartbeat, and a mention of its agent in the thread is `queued`, not taken as an answer. `GET /v1/agent/invocations` also returns `approvals`: the decided ones its connector has not yet confirmed; `POST /v1/agent/approvals/:approvalId/applied` confirms one (idempotent); `POST /v1/agent/approvals/:approvalId/close` with `{state: 'expired' | 'cancelled'}` settles an open one its agent gave up on (a settled one comes back unchanged). When the last open approval is settled, a `waiting` invocation goes back to `working`; an invocation that ends cancels its open approvals. **People's side:** `POST /v1/spaces/:spaceId/approvals/:approvalId/decide` with `{decision, note?, actingMode}`: any member of the space, acting directly (`actingMode: 'agent'` or an agent key is `forbidden`), REST only, no tool; one of the offered choices; a `note` only with a `deny`; the first decision wins (`invalid_request` after). The agent's connections get `approval_decided` (ephemeral; the listing is the guarantee). Older clients ignore the new field, event and frame, and show the card's body. +- **A binary file's read says where its bytes are** (`read_asset` — 2026-10-08, [spec §8](./SPEC.md#8-agents-in-spaces) Files in a message). No tool carries a file's bytes. On the agent face, `read_asset` of a binary version adds a second text block to the tool result: `GET <origin>/v1/spaces/<spaceId>/blobs/<hash>` with the caller's bearer, `<origin>` being the address the caller reached `/mcp` on. The structured result is `ReadAssetResult` unchanged, so both faces return the same object, and a text file's read has no second block. Additive. - **The roster is the whole org** (`listOrgMembers`, `GET /v1/members`, `list_members` without a space — 2026-09-29, [spec §5](./SPEC.md#5-the-space) answer 4). It returns every member of the org, people and agents, sorted by display name case-insensitively with the id breaking ties, and it's the same answer for every caller: inside one org, the org is the trust boundary. Until this date it was bounded to members sharing a space with the caller. Nothing else widens: DMs to any org member were already allowed, mentions of a non-member are still dropped when the message is saved, and a space's own roster (`listMembers`) is unchanged. The wire shape is unchanged, so older clients simply see more people. No pagination in v1. - **A departure ends both the act and the stream** (`Kernel.lockedAs`, the `space_removed` member frame — 2026-09-22). A member's write re-verifies access **inside the space lock** — the same `canAccessSpace` decision the gate ran, against the membership as it stands in the transaction — so a write that passed the gate and then waited behind a removal is refused (`forbidden`) instead of landing after the `left` event; nothing is written. When a membership ends, the org tells every connection the member holds with `space_removed {spaceId, by, at}` (`by` is the member on leave, the remover once removal exists); the live face drops that space's subscription **before** forwarding the frame, so no frame of the space follows the departure, and a re-subscribe is refused for private spaces. Amended 2026-09-23: a departed member can explicitly re-subscribe to an open space as a browser; writing still requires rejoining. Pending replay is canceled when its subscription is removed or replaced. Ephemeral, never replayed — the durable truth is the `membership` event (`left` / `removed`) on the space's own log. Pre-2026-09-22 clients drop the unknown frame by contract. - **Any member may rename a shared space** (`renameSpace`, the `space_renamed` event — 2026-09-09): Slack channel semantics, no owner. Direct spaces refuse — a DM is named by the other person. An identical name is an idempotent 200 no-op with no event; a real rename appends `space_renamed` with the full row and the actor, so every listing updates. diff --git a/apps/harbor/SPEC.md b/apps/harbor/SPEC.md index 8c1a0ec6f..7558a38d2 100644 --- a/apps/harbor/SPEC.md +++ b/apps/harbor/SPEC.md @@ -557,9 +557,9 @@ Every agent member, whatever runs it (a Hermes or OpenClaw gateway, Claude Code, - **A connector Harbor runs keeps a record per thread** *(2026-09-30)* (`agent_connection_threads`): the platform's session for that thread, how far delivery has reached, and what is in flight. Before each call to the platform it marks what it is sending. After a restart it settles every working invocation from that record and the platform's own history, and sends again only what it has confirmed never arrived. It posts the answer and reports done in one step that checks the invocation is still working, so a replay never answers twice. - **A connector mirrors what its agent does, then interprets it.** Where the agent does something Spaces also has, the connector does it in Spaces too, so the agent behaves as its users know it: Hermes's 👀, ✅ and ❌ land on the message that invoked it as its own reactions, its typing shows as typing in the thread, and its status phrase becomes the invocation's activity line. On top of that, the connector reads the agent's lifecycle for the contract: the turn is done when Hermes releases the thread's session, and Stop is Hermes's own `/stop`. What Spaces has no counterpart for yet, such as buttons other than approvals' (part 4), waits until it has one. - **A connector reads the thread when its agent expects it.** An invocation stays a pointer (above). Hermes hears only what mentions it, so its connector sends the thread's messages since the agent last spoke there with each mention, and hands it the Spaces tools for the rest. -- **An agent reads mentions as tokens, and learns the etiquette from one skill** *(2026-10-01, found in the first thread between Hermes and a Replicas agent)*. A connector hands its agent message bodies with their tokens kept and relabeled from the roster, as the agent face already does (§7 Mentions), and writes each message's author as a token too, so the agent can mention anyone in the thread by copying one. A mention of the agent is removed only before a command (`@Hermes /model` reaches it as `/model`); anywhere else it stays, and the connector tells the agent its own token, or the agent reads a blank where its name was. Shown plain names, the two agents answered each other with plain names, which reach no one, so no hand-off happened. Every connector also puts one fixed line where its agent reads instructions (Hermes's platform hint, the standing paragraph of the Replicas message): copy a token exactly to mention someone, since a bare @Name reaches no one; agents see only messages that mention them; mention a person or an agent whenever you need them to act or answer, and never to thank, acknowledge or sign off. Told only when to mention, both agents ended on a question to the other in plain text, which no one received: each took the other for a reader of the thread. A Replicas agent also asked its invoker to act with a plain @Name, taking it for a mention. Everything else an agent should know about Spaces (threads, hand-offs and their limit, DMs, files, which tools to use) is the `rowboat-spaces` skill ([`skills/rowboat-spaces`](../../skills/rowboat-spaces/SKILL.md)), in the Agent Skills format that Hermes, Claude Code, Codex and most others load, published once from this repository. Hermes's `SETUP.md` installs it, a Replicas environment takes this repository as a skill registry, and other agents run `npx skills add rowboatlabs/rowboat`. Harbor writes no briefing into each invocation: an agent loads a skill the way it loads its other instructions, and one file serves every kind of agent. Slack made the same choice for apps in 2017, when it stopped turning names into mentions: the id token is the contract. +- **An agent reads mentions as tokens, and learns the etiquette from one skill** *(2026-10-01, found in the first thread between Hermes and a Replicas agent)*. A connector hands its agent message bodies with their tokens kept and relabeled from the roster, as the agent face already does (§7 Mentions), and writes each message's author as a token too, so the agent can mention anyone in the thread by copying one. A mention of the agent is removed only before a command (`@Hermes /model` reaches it as `/model`); anywhere else it stays, and the connector tells the agent its own token, or the agent reads a blank where its name was. Shown plain names, the two agents answered each other with plain names, which reach no one, so no hand-off happened. Every connector also puts one fixed line where its agent reads instructions (Hermes's platform hint, the standing paragraph of the Replicas message): copy a token exactly to mention someone, since a bare @Name reaches no one; agents see only messages that mention them; mention a person or an agent whenever you need them to act or answer, and never to thank, acknowledge or sign off. Told only when to mention, both agents ended on a question to the other in plain text, which no one received: each took the other for a reader of the thread. A Replicas agent also asked its invoker to act with a plain @Name, taking it for a mention. Everything else an agent should know about Spaces (threads, hand-offs and their limit, DMs, files, which tools to use) is the `rowboat-spaces` skill ([`skills/rowboat-spaces`](../../skills/rowboat-spaces/SKILL.md)), in the Agent Skills format that Hermes, Claude Code, Codex and most others load, published once from this repository. Hermes's `SETUP.md` installs it, a Replicas environment takes this repository as a skill registry, and other agents run `npx skills add rowboatlabs/rowboat`. Harbor writes no briefing into each invocation: an agent loads a skill the way it loads its other instructions, and one file serves every kind of agent. Slack made the same choice for apps in 2017, when it stopped turning names into mentions: the id token is the contract. *Amended 2026-10-08:* the fixed line also tells the agent to load the skill once per session, and gives the address it is published at ([raw on GitHub](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/skills/rowboat-spaces/SKILL.md)), so an agent that cannot install skills reads the same page. The skill then holds the rest in one place: the setup to check (the MCP server, the key in the environment) and to name what is missing, what the tools are for, and how files download. Found when Hermes, with only a tool's description to go on, answered that it could not read a PNG in its space, though its key could download it. - **Who may make an agent act is Spaces' rule, and the agent's own on top.** Hermes's plugin is set up to accept anyone Spaces lets invoke it (`ROWBOAT_ALLOW_ALL_USERS`); its owner can narrow that in Hermes (`ROWBOAT_ALLOWED_USERS`). The agent's own commands (Hermes's `/model`, `/reset`) run from a space only for the agent's owner, and only when the owner turns that on (`ROWBOAT_OWNER_COMMANDS`, on in the app's setup): no one else can change the agent from a space. Approvals are not commands: they are cards that any person who can see them may decide (part 4, 2026-10-01). -- **Files in a message** *(2026-09-30)*. An attachment is a link in the message body to a file in the space; Harbor never copies bytes into an invocation (it is a pointer, above). The connector fetches what its agent needs on the agent's key (`GET /v1/spaces/:spaceId/blobs/:hash`, which only a member of the space may read) and hands it over the way its agent takes files: images to the model, other files to disk, never wholesale into the context, which is also why no tool returns file contents. The invoking message's attachments are delivered; earlier ones in the thread are listed by name and link for the agent to fetch. Hermes's connector passes them as Hermes's own inbound media; Replicas's sends images to the model and lists every attachment with its download address. +- **Files in a message** *(2026-09-30)*. An attachment is a link in the message body to a file in the space; Harbor never copies bytes into an invocation (it is a pointer, above). The connector fetches what its agent needs on the agent's key (`GET /v1/spaces/:spaceId/blobs/:hash`, which only a member of the space may read) and hands it over the way its agent takes files: images to the model, other files to disk, never wholesale into the context, which is also why no tool returns file contents. The invoking message's attachments are delivered; earlier ones in the thread are listed by name and link for the agent to fetch. Hermes's connector passes them as Hermes's own inbound media; Replicas's sends images to the model and lists every attachment with its download address. *Amended 2026-10-08:* a file in the space comes the same way: `read_asset` returns a binary file's hash, never its bytes, and on the agent face adds the address to download them from on the agent's key. - **Actions specific to one kind of agent wait** *(2026-09-30)*. A fork, attach or retry button for one platform is not built; such actions come later in a form every agent can use. Retrying is mentioning the agent again, and a platform's own view is the invocation's link. - **Agent37** *(2026-10-01)*. Agent37 hosts Hermes and OpenClaw (and coding harnesses) on instances, each serving one API that runs a turn (`POST /v1/responses`), so Harbor runs its connector, and Agent37 agents are `hermes` / `agent37` and `openclaw` / `agent37`; coding harnesses wait. Rejected: running Hermes's own plugin on the instance (it works, as `hermes` / `plugin`, for a Hermes kept awake), because an outbound connection neither wakes a sleeping instance nor survives its sleep, and OpenClaw has no plugin of ours. One credential, the workspace's `sk_live_` key, which reaches every instance in that workspace (Agent37 has no per-instance keys), so setup asks for a key made just for Rowboat. *Amended 2026-10-05:* an Agent37 agent is one instance (§4 Agent members): an instance "keeps files, connected accounts, and memory across every session", so it is an agent, not a place to run one, unlike a Replicas environment. The app finds or creates it with the person's key while adding the agent (setup is the app's, and Harbor creates nothing on a platform), and its template decides the kind; Harbor checks both before saving. A thread is one session on the agent's instance: the connector mints the session id from the thread, which Hermes and OpenClaw accept on a first turn. When the instance is gone, the agent says so and stops; it never moves to another. (On 2026-10-01 the instance was an invocation option chosen per thread, which let one agent answer from several instances with different memories; reversed in review.) Its options are Model and Effort *(amended 2026-10-08)*. Models are listed per instance on its own address, and any request there wakes the instance and keeps it awake, so the list is never polled: it is read at start when the hosting API (which wakes nothing) says the instance is running, and after a turn, when it is awake anyway, and the last list declared stands in between and across restarts. On 2026-10-01 that cost kept Model out. Effort is the eight levels every harness takes. Agent37 has no idempotency key: the per-thread record marks a send before it is made, the session refuses a second turn while one runs, and after a restart the connector reattaches to the turn it recorded or reads the session's history for the request's marker, sending only when neither shows it. Stop is Agent37's cancel. Agent37 runs its harnesses unattended, so its agents raise no approvals (part 4): one that needs a person ends its turn with the question, and the next mention answers it. Its files go onto the instance's disk and their paths with the turn; Agent37 takes no image bytes or links. It has no view of a turn to link to. - **Standing instructions** (a system prompt per agent or per space) are Deferred. Most agents' own interfaces take one message per turn and keep their persona in their own configuration, so when they come they will be one optional field on the invocation, which each connector maps as best it can. diff --git a/apps/harbor/packages/protocol/src/mcp.ts b/apps/harbor/packages/protocol/src/mcp.ts index 4eb401285..812e59823 100644 --- a/apps/harbor/packages/protocol/src/mcp.ts +++ b/apps/harbor/packages/protocol/src/mcp.ts @@ -482,8 +482,8 @@ export const readAsset = tool({ 'content, current version, and recent change history. Always read before proposing a change; ' + 'the version you read is your base version. Pass `version` to read an older version (time ' + 'travel); omit for the current one. Binary files (images, pdfs, uploads) return empty content ' + - 'plus a `blob` {hash, size, mime} — describe them by their metadata; the bytes are not readable ' + - 'over this face.', + 'plus a `blob` {hash, size, mime}: no tool carries the bytes, which download by that hash from ' + + 'GET /v1/spaces/{spaceId}/blobs/{hash} on the org\'s address.', input: z.object({ spaceId: SpaceId, assetId: AssetId, version: z.number().int().positive().optional() }), output: ReadAssetResult, }); diff --git a/apps/harbor/packages/server/src/connectors/thread-prompt.ts b/apps/harbor/packages/server/src/connectors/thread-prompt.ts index 8ca70c6e9..7f07ce7d5 100644 --- a/apps/harbor/packages/server/src/connectors/thread-prompt.ts +++ b/apps/harbor/packages/server/src/connectors/thread-prompt.ts @@ -59,6 +59,13 @@ export function downloadLine(orgUrl: string, spaceId: string, a: Attachment): st return `- ${a.name} (${a.mime}, ${formatSize(a.size)}): ${orgUrl}/v1/spaces/${spaceId}/blobs/${a.hash}?name=${encodeURIComponent(a.name)}`; } +/** + * Where any agent can read the rowboat-spaces skill without installing it (spec §8, 2026-10-08): the + * copy on GitHub, the one place it is published. The standing paragraph points every turn here, so an + * agent that cannot install skills still learns its setup, the tools and files from one page. + */ +export const SKILL_URL = 'https://raw.githubusercontent.com/rowboatlabs/rowboat/main/skills/rowboat-spaces/SKILL.md'; + /** The standing paragraph: who the agent is and who asked, as tokens, and when to mention (spec §8, 2026-10-01). */ export function standingParagraph(input: { invocation: Invocation; agentId: string; names: ReadonlyMap<string, string>; orgAddress: string }): string { const { invocation, names } = input; @@ -74,7 +81,8 @@ export function standingParagraph(input: { invocation: Invocation; agentId: stri 'To mention someone, copy their token exactly as it appears here; a bare @Name is plain text that reaches no one. ' + 'Agents see only messages that mention them. Whenever you need a person or an agent to act or answer, mention them, ' + 'and otherwise write their name; never mention to thank, acknowledge or sign off. ' + - 'For more on Rowboat, use the rowboat-spaces skill. ' + + `If you have not loaded the rowboat-spaces skill in this session, load it now, or read it at ${SKILL_URL}: ` + + 'it covers your setup, the tools and files. ' + `Include the following attribution in every PR description you create or update for this request: Requested by ${invoker}, ${thread}. ` + 'Do not post to Slack.' ); diff --git a/apps/harbor/packages/server/src/mcp.ts b/apps/harbor/packages/server/src/mcp.ts index a9f6a01ae..d356d6a79 100644 --- a/apps/harbor/packages/server/src/mcp.ts +++ b/apps/harbor/packages/server/src/mcp.ts @@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import { mcpTools, NewPoll, type ActingMode, type ActivityItem, type ActivityKind, type SearchKind, relabelMentions, type Message } from '@rowboat/spaces-protocol'; +import { mcpTools, NewPoll, type ActingMode, type ReadAssetResult, type ActivityItem, type ActivityKind, type SearchKind, relabelMentions, type Message } from '@rowboat/spaces-protocol'; import { z } from 'zod'; import { authenticateRequest, wwwAuthenticate, type OrgAuth } from './auth.js'; import { HarborError } from './errors.js'; @@ -57,7 +57,7 @@ export async function handleMcpRequest(req: IncomingMessage, res: ServerResponse return; } - const server = buildMcpServer(deps.service, actor); + const server = buildMcpServer(deps.service, actor, publicOriginOf(req)); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, @@ -79,7 +79,7 @@ export async function handleMcpRequest(req: IncomingMessage, res: ServerResponse } } -function buildMcpServer(service: HarborService, actor: McpActor): Server { +function buildMcpServer(service: HarborService, actor: McpActor, origin: string): Server { const server = new Server({ name: 'harbor-stub', version: '0.0.1' }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, () => ({ @@ -102,7 +102,7 @@ function buildMcpServer(service: HarborService, actor: McpActor): Server { try { const result = await dispatch(service, actor, request.params.name, parsed.data); return { - content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }, ...binaryNote(origin, request.params.name, parsed.data, result)], structuredContent: result as Record<string, unknown>, }; } catch (err) { @@ -121,6 +121,22 @@ function errorResult(text: string) { return { content: [{ type: 'text' as const, text }], isError: true }; } +// A binary file's bytes never pass through a tool (spec §8, Files); its read says where they are +// (2026-10-08). Told only that the bytes were "not readable over this face", Hermes answered that it +// could not read a PNG in its space (2026-10-06), when its own key could download it. The structured +// result is unchanged, so both faces still return the same object. +function binaryNote(origin: string, tool: string, args: unknown, result: unknown) { + if (tool !== 'read_asset') return []; + const { blob } = result as ReadAssetResult; + if (!blob) return []; + const { spaceId } = args as { spaceId: string }; + return [{ + type: 'text' as const, + text: `This file is binary, so read_asset carries no bytes. Download them with the bearer you call this ` + + `server with: GET ${origin}/v1/spaces/${spaceId}/blobs/${blob.hash}`, + }]; +} + async function dispatch( service: HarborService, actor: McpActor, diff --git a/apps/harbor/packages/server/test/mcp.test.ts b/apps/harbor/packages/server/test/mcp.test.ts index 548263c33..5fc932fab 100644 --- a/apps/harbor/packages/server/test/mcp.test.ts +++ b/apps/harbor/packages/server/test/mcp.test.ts @@ -281,6 +281,33 @@ describe('agent face (MCP)', () => { await client.close(); }); + it('read_asset of a binary file says where its bytes download, and they download there (2026-10-08)', async () => { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); + const hash = createHash('sha256').update(bytes).digest('hex'); + await fetch(`${harbor.url}/v1/spaces/${spaceId}/blobs`, { + method: 'PUT', + headers: { authorization: 'Bearer dev-harsh', 'x-blob-sha256': hash }, + body: bytes, + }); + const client = await mcpClient('dev-harsh'); + const filed = (await client.callTool({ + name: 'create_asset', + arguments: { spaceId, path: 'images/mock.png', blob: hash, reason: 'a binary file to read' }, + })).structuredContent as { asset: { id: string } }; + + const read = await client.callTool({ name: 'read_asset', arguments: { spaceId, assetId: filed.asset.id } }); + expect((read.structuredContent as { content: string; blob: { hash: string } })).toMatchObject({ content: '', blob: { hash } }); + const url = `${harbor.url}/v1/spaces/${spaceId}/blobs/${hash}`; + expect((read.content as Array<{ text: string }>)[1]!.text).toContain(`GET ${url}`); + const downloaded = await fetch(url, { headers: { authorization: 'Bearer dev-harsh' } }); + expect(new Uint8Array(await downloaded.arrayBuffer())).toEqual(bytes); + + // A text file's read carries its content, and no note. + const text = await client.callTool({ name: 'read_asset', arguments: { spaceId, assetId: roadmapId } }); + expect(text.content as unknown[]).toHaveLength(1); + await client.close(); + }); + it('reason is required on this face', async () => { const client = await mcpClient('dev-harsh'); const result = await client.callTool({ diff --git a/skills/rowboat-spaces/SKILL.md b/skills/rowboat-spaces/SKILL.md index c0feb09fa..b5b48f72b 100644 --- a/skills/rowboat-spaces/SKILL.md +++ b/skills/rowboat-spaces/SKILL.md @@ -1,18 +1,27 @@ --- name: rowboat-spaces -description: How to behave as a member of Rowboat Spaces, the team chat where people and agents work together. Use it whenever a request reaches you from Rowboat, and before you mention someone, hand work to another agent, read more of a thread, or post anywhere in Spaces. +description: How to work as a member of Rowboat Spaces, the team chat where people and agents work together. Load it once per session, before acting on any request that reaches you from Rowboat; it covers your setup, mentions and hand-offs, the tools, and files. --- # Rowboat Spaces Rowboat Spaces is a team chat. People and agents are both members, and you are one of them. Every request reaches you from a message in a space or a direct message (DM), and your reply is posted as you. +## Your setup + +You reach Rowboat on your own agent key (`rbk_…`), in two ways: + +- **The Rowboat MCP server** gives you the tools below. It is `https://<org>/mcp` with the header `Authorization: Bearer <your key>`, where `<org>` is the host of any Rowboat link you are shown. If tools such as `read_thread` and `post_message` are available to you, it is connected. +- **HTTP with your key** downloads files. Your environment holds the key as `ROWBOAT_AGENT_KEY`. + +When a request needs one you don't have, do what you can without it and say in your reply which one is missing, so the person can set it up where you run. Never ask for the key in the chat, and never print it. + ## Where you are - **A conversation is a thread.** You were asked in one, and your answer goes in that thread. Start a new top-level message, or post in another space or DM, only when someone asks you to. - **In a space, a mention brings you in.** In a DM with you, every message does, mention or not. - **Replies render as Markdown.** Keep them short enough to read in a chat. -- **You see what is new.** Each request comes with the thread's messages since you last heard from it. Read the rest with the tools below when you need it. +- **You see what is new.** Each request comes with the thread's messages since you last heard from it. Read the rest with the tools when you need it. ## Mentions @@ -38,16 +47,26 @@ A mention hands someone the thread: a person is notified, and an agent starts a - **Hand-offs stop at three hops.** Past that, Rowboat refuses the mention and says why under your message. `get_invocations` shows what agents are doing in a thread. - **When you hand work to another agent, say so in your reply,** so the people in the thread know who has it. -## Reading and acting +## What the tools let you do -With Rowboat's MCP server connected, you act through its tools, on your own key: +Everything a person can do in Spaces, you can do through the tools, as yourself: -- `read_thread`: the whole thread, when what you were given is not enough. -- `search_space`: earlier messages, topics and files in a space. -- `read_stream`: a space's top-level messages. -- `list_members` and `whoami`: who is who, and who you are. -- `post_message` (with `threadRoot` to reply in a thread) and `react` (pass the emoji itself, such as 👍). +- **Read:** a whole thread (`read_thread`), a space's top-level messages, a search over messages, topics and files (`search_space`), and what involves you across spaces. +- **Talk:** post in a thread or start one (`post_message`), react, edit or delete your own messages, open a DM, run a poll. +- **Work on files:** list, read, create and edit the space's files, with their history and diffs. An edit is proposed against the version you read (`propose_change`), and Rowboat merges it or hands back a conflict to retry. +- **Organize:** topics, spaces, members and invites. + +Each tool describes its own inputs. Discover ids with the tools (`list_spaces`, `list_members`); never guess them. ## Files -A file in a message is a link to it in the space. Rowboat hands you the files of the message that asked you, or their download addresses. Fetch an earlier one only when the request needs it, and never paste a whole file into the thread. +- **Text files** come back from `read_asset` with their content. +- **Binary files** (images, PDFs, uploads) never come through a tool. `read_asset` returns the file's `blob.hash` instead, and a file in a message is a link `https://<org>/s/<spaceId>/b/<hash>`. Download the bytes over HTTP, on your key, wherever you can read them: + + ``` + curl -fsSL -H "Authorization: Bearer $ROWBOAT_AGENT_KEY" -o <name> https://<org>/v1/spaces/<spaceId>/blobs/<hash> + ``` + + Anyone who can read the space can download its files. Rowboat may answer with a redirect to storage, which `-L` follows. +- **The files of the message that asked you** usually arrive with it, or with their download addresses. Fetch an earlier one only when the request needs it. +- **Never paste a whole file into the thread.**