Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/harbor/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Two pnpm workspace packages under `packages/`:
|---|---|
| `core/kernel.ts` | store, hub, org, the read-only knob, the space lock with its publish-after-commit outbox, `append` / `nextOffset` / `appendNext`, `requireSpace` / `requireReadableSpace` / `requireMember`, `guardWrite`, `attributionOf` |
| `core/spaces.ts` | spaces, direct messages, invites and the bind ceremony, the roster, `me`, agent members (`createAgent`), push registration, the read-gated replay and membership-gated live relays |
| `core/agents.ts` | agent members' owners and keys: add an agent, list the ones a member manages, create and revoke keys |
| `core/agents.ts` | agent members' owners and keys: add an agent, list the ones a member manages, create and revoke keys; a platform agent's alert hook (set, clear, and the pre-auth delivery's secret check) |
| `core/invocations.ts` | invoking agent members (spec §8): the trigger `Feed.postMessage` runs in its transaction, the per-(agent, conversation) queue, the connector's operations, cancel and stop, and approvals (part 4: raised with their card in `Feed.postMessage`'s transaction, decided by a person, settled by the connector; `approval-card.ts` writes the card's text); its frames leave after the commit through its own outbox |
| `core/assets.ts` | assets by id, versions, the change log, blobs, history, diff |
| `core/images.ts` | profile images: a member's avatar and the org's logo, the org-wide image registry (`org_images`) and its read gate |
Expand All @@ -30,7 +30,7 @@ Two pnpm workspace packages under `packages/`:
| `deployment.ts`, `directory.ts`, `apex.ts` | many orgs from one process: host → org runtime; the org directory; the apex face (create org, my orgs) |
| `notify.ts`, `push.ts` | the one notification decision; Expo delivery |
| `hub.ts`, `blobs*.ts`, `mime.ts`, `merge.ts`, `search.ts`, `mentions-backfill.ts` | in-process fan-out, blob drivers, sniffing, the three-way merge, query parsing, the mentions backfill |
| `connectors/` | connectors Harbor runs for platform agents (spec §8 Connectors): `platforms.ts` (the registry), `host.ts` (one per platform agent, started with the org), `replicas/` (the Replicas connector: its API client and event stream, turn reading, the prompt), `agent37/` (the Agent37 connector: its API client and SSE parser, the turn loop, the prompt), `conductor/` (the Conductor connector: its API client, polling, transcript reading), `common/` (what the coding-agent connectors share: the thread context and files, reading a coding agent's events), `jev/` (Jev, the agent Harbor itself is: its TypeSafe client, the tag questions, the per-space pass over every message), `thread-prompt.ts` (what both write into a prompt: attachments, the request marker, the standing paragraph); `sealing.ts` seals their platform keys under `HARBOR_INTEGRATION_KEY` |
| `connectors/` | connectors Harbor runs for platform agents (spec §8 Connectors): `platforms.ts` (the registry), `host.ts` (one per platform agent, started with the org), `replicas/` (the Replicas connector: its API client and event stream, turn reading, the prompt), `agent37/` (the Agent37 connector: its API client and SSE parser, the turn loop, the prompt), `conductor/` (the Conductor connector: its API client, polling, transcript reading), `common/` (what the coding-agent connectors share: the thread context and files, reading a coding agent's events), `jev/` (Jev, the agent Harbor itself is: its TypeSafe client, the tag questions, the per-space pass over every message), `thread-prompt.ts` (what both write into a prompt: attachments, the request marker, the standing paragraph); `commands.ts` (bots: a service's agent answering commands from its API, 2026-10-03) with `posthog/` and `cal/`, each a client, its commands and what its alerts say; `sealing.ts` seals their platform keys under `HARBOR_INTEGRATION_KEY` |
| `stats.ts`, `internal.ts` | the live-load counters (connections, subscriptions, frames per minute by kind, deliveries) and the operator face that reads them, `GET /internal/stats` behind `HARBOR_INTERNAL_KEY` |

`examples/echo-agent.mjs` is the smallest connector for the agent contract (spec §8): one agent's key, the live frame plus a list every minute, acknowledge, report, reply in the thread. Run it against a dev Harbor to watch invocations end to end, and start a real connector from its shape. The Hermes connector is that shape as a Hermes platform plugin, in its own repo ([rowboatlabs/hermes-rowboat](https://github.com/rowboatlabs/hermes-rowboat)); nothing in Harbor is specific to it. The Replicas, Agent37 and Conductor connectors are the same shape run inside Harbor (`connectors/replicas/`, `connectors/agent37/`, `connectors/conductor/`), because Harbor calls those platforms; `test/fake-replicas.ts`, `test/fake-agent37.ts` and `test/fake-conductor.ts` stand in for their APIs in the tests. Jev's connector (`connectors/jev/`, spec §8 Jev) is run the same way but is not invoked: it reads every message in its spaces from a per-space cursor and posts tags through `relayMentions`; `test/fake-typesafe.ts` stands in for TypeSafe's API as OpenRouter serves it.
Expand Down Expand Up @@ -115,7 +115,7 @@ Verified against Supabase Auth (2026-08-18/19), the flagship AS: tokens on a sha

## Ship

- `HARBOR_MODE=deployment` with `DATABASE_URL`, `APEX_DOMAIN`, `AUTH_ISSUER`; optional `AUTH_PUBLISHABLE_KEY`, `BLOBS_S3_*` or `BLOBS_DIR`, `HARBOR_MAX_BLOB_BYTES`, `DATABASE_POOL_MAX`, `HARBOR_INTERNAL_KEY`, `HARBOR_INTEGRATION_KEY` (sealing platform agents' keys; without it Replicas, Agent37 and Conductor agents can't be added), `HARBOR_JEV_OPENROUTER_KEY` (Rowboat's OpenRouter key, which Jev's calls to TypeSafe go through; without it orgs get no Jev, and an existing Jev stays idle). The `Dockerfile` header lists them. Migrations self-apply at boot under an advisory lock; orgs are created on the apex face.
- `HARBOR_MODE=deployment` with `DATABASE_URL`, `APEX_DOMAIN`, `AUTH_ISSUER`; optional `AUTH_PUBLISHABLE_KEY`, `BLOBS_S3_*` or `BLOBS_DIR`, `HARBOR_MAX_BLOB_BYTES`, `DATABASE_POOL_MAX`, `HARBOR_INTERNAL_KEY`, `HARBOR_INTEGRATION_KEY` (sealing platform agents' keys; without it Replicas, Agent37, Conductor, PostHog and Cal.com agents can't be added), `HARBOR_JEV_OPENROUTER_KEY` (Rowboat's OpenRouter key, which Jev's calls to TypeSafe go through; without it orgs get no Jev, and an existing Jev stays idle). The `Dockerfile` header lists them. Migrations self-apply at boot under an advisory lock; orgs are created on the apex face.
- **Server before app.** A breaking wire change deploys the server first and the app build the same day; an additive change needs no coupling. Say which in the PR.
- **One instance.** Autoscaling stays off until everything in [SPEC.md §4 *Running more than one instance*](SPEC.md#running-more-than-one-instance--deferred-added-2026-09-30) is addressed: the hub's shared bus, and the lease for connectors Harbor runs. `GET /internal/stats` with the operator key shows what the instance carries — connections, subscriptions, frames per minute by kind, deliveries — the numbers that say when one stops being enough; the same line lands in the log once a minute whether or not the key is set. When a second instance comes, the counters feed an OpenTelemetry push and the hub gets its bus in the same change.
- The S3 blob driver's conformance suite runs when `HARBOR_TEST_S3_BUCKET` is set; the disk driver's always.
Expand Down
1 change: 1 addition & 0 deletions apps/harbor/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ How the server is built — modules, invariants, the slice a capability cuts thr
- **A member has a kind** (`Member.kind`, migration 024 — 2026-09-29, [spec §4](./SPEC.md#4-orgs-and-identity) Agent members). `human | agent`, defaulting to `human` for payloads from older servers; migration 024 marks every existing row `human`. Kind is fixed at creation: no write changes it. An agent member is a member on the wire in every other way (a minted id, a `displayName`, memberships, its acts attributed to its own id) and is never `admin`, which the database refuses. No sign-in identity maps to it: a person adds it (`createAgent`, next bullet). Distinct from `actingMode: 'agent'`, which is a person's own agent acting as that person. Additive: older clients drop the field.
- **An agent member has an owner and its own keys** (`Member.ownerId`, `AgentKey`, `listAgents`, `createAgent`, `createAgentKey`, `revokeAgentKey`, migration 026 — 2026-09-29, [spec §4](./SPEC.md#4-orgs-and-identity) Agent members). `POST /v1/agents {displayName}` adds an agent owned by the caller, who must be a person, and returns it with its first key: `AgentKeySecret` carries `secret` (`rbk_` plus 43 base64url characters) this once, and every later read is `AgentKey` metadata (`createdBy`, `createdAt`, `lastUsedAt`, `revokedAt`). The org stores only a SHA-256 of the secret. `POST /v1/agents/:agentId/keys` adds a key, owner only, and an admin is refused. `POST /v1/agents/:agentId/keys/:keyId/revoke` is the owner's or any admin's, and is idempotent: the first revocation time stands. `GET /v1/agents` lists the agents the caller manages with their keys: their own, or every agent for an admin. A new agent, which is in no space yet, is on the org roster like every member (the next bullet), so anyone can find it and add it to a space. A key is accepted as the bearer on every face (REST, the live face's `token`, MCP) and resolves to the agent. Its acts are always `actingMode: 'direct'`: a body's `actingMode`/`agentName` and MCP's `x-acting-mode`/`x-agent-name` are ignored. `lastUsedAt` is recorded at most hourly. An unknown or revoked key is `unauthorized`. There are no agent tools for any of this: a secret never passes through a tool.
- **An agent has a kind and a connection** (`Member.agentKind`, `Member.agentConnection`, `AGENT_PAIRS`, `HARBOR_RUN_CONNECTIONS`, `AgentCredential`, `setAgentCredential`, migration 028 — 2026-09-30, [spec §4](./SPEC.md#4-orgs-and-identity) Agent members and [§8](./SPEC.md#8-agents-in-spaces) Connectors). Both are set on every agent and absent on people, fixed at creation, and open strings on the wire, so a client draws a kind it doesn't know as a generic agent; migration 028 marks every existing agent `custom` / `contract`. `createAgent` takes `kind` and `connection` (absent: `custom` / `contract`, so older clients are unaffected), and a pair not in `AGENT_PAIRS` is `invalid_request`. A pair whose connection is in `HARBOR_RUN_CONNECTIONS` (`replicas`; `conductor` with `claude-code`, 2026-10-06) also needs `credential`, the platform's key: Harbor checks it with the platform before anything is created (a refusal is `invalid_request` with the platform's reason, and nothing is written) and seals it; any other connection takes none. `PUT /v1/agents/:agentId/credential {secret}` replaces it, owner only, checked the same way, and clears a rejection. The secret is never returned: `listAgents` carries `credential` (`hint`, the last four characters; `setBy`; `setAt`; and `rejectedAt` with `rejectedReason` once the platform has refused it). Such an agent's keys work like any agent's; Harbor runs its connector, which acts as the agent in-process. *Amended 2026-10-01:* `agent37` joins `HARBOR_RUN_CONNECTIONS`, with the pairs `hermes` / `agent37` and `openclaw` / `agent37` (`AGENT37_AGENTS`; `openclaw` is a new kind); its credential is an Agent37 `sk_live_` key, checked by listing the workspace's instances. Its connector declares `stop` and the option `reasoning` (a select of Agent37's `reasoning_effort` levels). *Amended 2026-10-05:* `Member.agentInstance` (migration 032) is the platform instance an agent is, for a connection in `INSTANCE_CONNECTIONS` (`agent37`): `createAgent` takes `instance`, required for those connections and refused (`invalid_request`) for any other; Harbor checks the key reaches it and that its template runs the agent's `kind` (`agent37-hermes` or `agent37-hermes-small` for `hermes`, `agent37-openclaw` for `openclaw`) before anything is written, and `setAgentCredential` checks a new key reaches the same instance. Fixed at creation and not secret: it rides on the member like `agentKind`, absent on every other agent and on people; older clients drop it. A Conductor agent's connector also keeps a key of its own for its workspaces (migration 034, 2026-10-06): it appears in `listAgents` among the agent's `keys` with `createdBy` the agent itself, and revoking it is honored at once; the connector makes a new one for the next workspace.
- **Integration agents and alert hooks** (`posthog` / `posthog` and `cal` / `cal` in `AGENT_PAIRS` and `HARBOR_RUN_CONNECTIONS`; `AgentHook`, `setAgentHook`, `clearAgentHook`, `receiveAgentHook`, migration 036 — 2026-10-03, [spec §8](./SPEC.md#8-agents-in-spaces) Integrations and Alerts). The two pairs are platform agents like `replicas`: `createAgent` needs the service's key as `credential` (a PostHog personal API key, or `https://<self-hosted PostHog> <key>`; a Cal.com API key), checked with the service first. `PUT /v1/agents/:agentId/hook {spaceId}` is owner only, for a platform agent, in a space where the caller and the agent are both members (otherwise `forbidden`, or `invalid_request` naming the agent to add); it returns `{hook, url}`, where `url` (`/v1/hooks/:agentId/:token` on the org's public origin) is shown this once and replaces any earlier one. `DELETE /v1/agents/:agentId/hook` is owner or admin, idempotent. `listAgents` carries `hook` (`spaceId`, `setBy`, `setAt`), never the address. `POST /v1/hooks/:agentId/:token` is pre-auth: a JSON body up to 256 KB (`payload_too_large` past it, `invalid_request` when it isn't JSON), `not_found` for an unknown agent or a wrong secret alike, and `{posted}`: true when the agent posted the alert into the hook's space, false for an event its connector leaves unsaid. Render face only, like keys: an address is a secret.
- **Any member adds existing members to a space they are in** (`addMembers`, `add_members`, the membership event's `by` — 2026-09-29, [spec §4](./SPEC.md#4-orgs-and-identity) Roles). `POST /v1/spaces/:spaceId/members {memberIds}` adds org members, people or agents, to a shared space the caller is a member of; browsing an open space is not enough, and direct spaces refuse (`invalid_request`). It is all or nothing: an id that is not an org member is `not_found` and nothing is written. Each member added gets the ordinary `membership` `joined` event, now carrying `by` (the adder's `Attribution`, so an agent's add reads "via"), and a `space_added` frame on their member channel after the commit. `by` is absent whenever the member acted themselves (accepting an invite, self-join, leaving), so an older client that drops it still reads "joined". Anyone already in is a no-op: `memberships` answers for every id asked, in the order asked, and a request that adds nobody writes nothing and passes a read-only org. The added member is not notified in v1 (no `notify` frame, no push, no Activity row): the space appears in their sidebar, and the stream's join line says who added them (the next bullet).
- **The stream shows membership as lines between messages** (`listStream` `events`, `StreamEvent` — 2026-09-29). Each page carries, oldest first, the `membership` events (joined, left, removed, with `by` when someone else acted) whose offsets fall in the range the page answers for: an event belongs to the page holding the next message after it, and the newest page also takes the events after its newest message, so paging back, forward, or around a message shows each event exactly once, and a space nobody has posted in still shows its joins. This is the Matrix model, not Slack's: the fact stays in the log and the client draws the line, so no system message exists for unread counts, notifications, search, Activity, or agents to skip. Live, the same events arrive as ordinary `event` frames. Older orgs omit the field; it defaults to empty.
- **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.
Expand Down
Loading
Loading