From 9f5bb1cde6dfe5495605560321c585790489ca48 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Sat, 3 Oct 2026 09:22:42 +0000 Subject: [PATCH 1/2] Add PostHog and Cal.com agents, and alert hooks for platform agents @PostHog and @Cal answer commands from their services' APIs on the org's key, as their Slack apps do, with no model in the loop. A shared command connector (connectors/commands.ts) holds everything but each service's client, commands and alert wording, so another service is one module. A platform agent's owner can point its alerts at a space: Harbor mints a secret address (shown once, stored as a hash) for the service's webhook settings, and the agent posts what each alert says into that space. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: arkml <6592213+arkml@users.noreply.github.com> Co-Authored-By: replicas-connector[bot] --- apps/harbor/AGENTS.md | 6 +- apps/harbor/CONTRACT.md | 1 + apps/harbor/SPEC.md | 4 +- apps/harbor/packages/protocol/src/api.ts | 34 +- apps/harbor/packages/protocol/src/core.ts | 21 +- .../server/src/connectors/cal/index.ts | 269 +++++++++++++++ .../server/src/connectors/commands.ts | 273 +++++++++++++++ .../packages/server/src/connectors/host.ts | 5 + .../server/src/connectors/platforms.ts | 10 + .../server/src/connectors/posthog/index.ts | 216 ++++++++++++ .../harbor/packages/server/src/core/agents.ts | 59 +++- apps/harbor/packages/server/src/http.ts | 31 ++ apps/harbor/packages/server/src/main.ts | 2 +- apps/harbor/packages/server/src/migrations.ts | 18 + apps/harbor/packages/server/src/pg-store.ts | 23 ++ apps/harbor/packages/server/src/policy.ts | 12 + apps/harbor/packages/server/src/runtime.ts | 1 + apps/harbor/packages/server/src/service.ts | 14 + apps/harbor/packages/server/src/store.ts | 12 + .../packages/server/test/integrations.test.ts | 325 ++++++++++++++++++ .../packages/server/test/policy.test.ts | 6 + apps/x/apps/main/src/spaces/ipc.ts | 7 + .../src/components/spaces/agent-page.tsx | 99 +++++- .../src/components/spaces/agent-setup.tsx | 2 +- .../components/spaces/agents-dialog.test.tsx | 37 +- apps/x/apps/renderer/src/lib/agent-kinds.ts | 67 +++- apps/x/packages/core/src/spaces/client.ts | 12 +- apps/x/packages/shared/src/ipc.ts | 9 + apps/x/packages/shared/src/spaces.ts | 2 + 29 files changed, 1557 insertions(+), 20 deletions(-) create mode 100644 apps/harbor/packages/server/src/connectors/cal/index.ts create mode 100644 apps/harbor/packages/server/src/connectors/commands.ts create mode 100644 apps/harbor/packages/server/src/connectors/posthog/index.ts create mode 100644 apps/harbor/packages/server/test/integrations.test.ts diff --git a/apps/harbor/AGENTS.md b/apps/harbor/AGENTS.md index 53f1019b1..5fbed3703 100644 --- a/apps/harbor/AGENTS.md +++ b/apps/harbor/AGENTS.md @@ -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 | @@ -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); `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); `commands.ts` (integrations: 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 connector is the same shape run inside Harbor (`connectors/replicas/`), because Harbor calls Replicas; `test/fake-replicas.ts` stands in for Replicas's API in its tests. @@ -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 agents can't be added). 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, PostHog and Cal.com agents can't be added). 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. diff --git a/apps/harbor/CONTRACT.md b/apps/harbor/CONTRACT.md index cc565dd43..6012fa7b6 100644 --- a/apps/harbor/CONTRACT.md +++ b/apps/harbor/CONTRACT.md @@ -27,6 +27,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`) 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. +- **Integration agents and alert hooks** (`posthog` / `posthog` and `cal` / `cal` in `AGENT_PAIRS` and `HARBOR_RUN_CONNECTIONS`; `AgentHook`, `setAgentHook`, `clearAgentHook`, `receiveAgentHook`, migration 032 — 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:// `; 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. - **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. diff --git a/apps/harbor/SPEC.md b/apps/harbor/SPEC.md index 3844ea5b0..edd3e1c7e 100644 --- a/apps/harbor/SPEC.md +++ b/apps/harbor/SPEC.md @@ -547,7 +547,7 @@ Every agent member, whatever runs it (a Hermes or OpenClaw gateway, Claude Code, ### Connectors — **Decided** *(added 2026-09-30)* - **A connector lives where its agent can host it.** When the agent has a plugin or extension system, the connector is a plugin in the agent's own ecosystem, installed where the agent runs, and it is a client of the contract above on the agent's key, as the echo example is. Hermes's is the Rowboat platform plugin (`rowboatlabs/hermes-rowboat`), built by Hermes's platform-adapter guide: an agent's own plugin system hands a connector the agent's real lifecycle (turn start and end, session release, stop), which a relay or a bridge can only guess at, and it runs beside the agent's other channels. Harbor holds nothing specific to such an agent. **When Harbor must call the agent's platform instead** (Replicas, 2026-09-30; later Agent37), the connector lives in Harbor in its own module (`src/connectors//`, with its own tests) and is still a client of the contract above, in-process: it acts as the agent through the same operations, and wakes on the agent's own frames and the one-minute list. Harbor starts one for each agent whose connection names that platform (§4 Agent members). It is the only kind that stores anything, below. -- **Setup is the app's.** Adding an agent starts with how it connects (Hermes; Replicas, with its coding agent; or Custom), which sets its kind and connection, with the official logos. Each says what to do on the agent's side, filled in with the new key and the org's address. A platform connection's setup is a form: the platform's key and choices, checked against the platform on Add, then any step on the platform's side (Replicas: giving its environment our MCP server and the `rowboat-spaces` skill). When the agent can set itself up, that comes first: for Hermes, the person saves the three values only they have (address, key, home channel) and sends Hermes a fixed message pointing at the plugin's `SETUP.md`, which saves the rest and installs the plugin; the key never enters a chat. Doing the same by hand, from a terminal or the Hermes dashboard, stays as the fallback. **The steps stay on the agent's page** *(2026-10-01)*: after setup, the Agents screen opens each agent's page with its option defaults, the same setup steps (the MCP server, the skill, the settings) filled in with the org's address and the home channel, and its keys. Harbor keeps only a key's hash, so the steps show a placeholder for the key until its owner makes a new one there, which fills them in. +- **Setup is the app's.** Adding an agent starts with how it connects (Hermes; Replicas, with its coding agent; PostHog or Cal.com, 2026-10-03; or Custom), which sets its kind and connection, with the official logos. Each says what to do on the agent's side, filled in with the new key and the org's address. A platform connection's setup is a form: the platform's key and choices, checked against the platform on Add, then any step on the platform's side (Replicas: giving its environment our MCP server and the `rowboat-spaces` skill). When the agent can set itself up, that comes first: for Hermes, the person saves the three values only they have (address, key, home channel) and sends Hermes a fixed message pointing at the plugin's `SETUP.md`, which saves the rest and installs the plugin; the key never enters a chat. Doing the same by hand, from a terminal or the Hermes dashboard, stays as the fallback. **The steps stay on the agent's page** *(2026-10-01)*: after setup, the Agents screen opens each agent's page with its option defaults, the same setup steps (the MCP server, the skill, the settings) filled in with the org's address and the home channel, and its keys. Harbor keeps only a key's hash, so the steps show a placeholder for the key until its owner makes a new one there, which fills them in. - **A platform credential is sealed, not hashed** *(2026-09-30)*. Harbor presents it to the platform, where an agent key is only ever checked (§4), so it must be recoverable. One per agent (`agent_connection_credentials`), AES-256-GCM under a deployment key with key ids for rotation (`HARBOR_INTEGRATION_KEY`), each value bound to its org and agent so it opens nowhere else. It is entered and replaced only in the app, over REST, checked against the platform before it is saved; never read back, only shown by its last characters; never through a tool (the parity exception keys already have, §4). When the platform later rejects it, each invocation fails fast with the reason, the Agents screen marks the key rejected, and the agent tells its owner once, in their DM. A cloud KMS takes over from the deployment key when Harbor's other secrets move to a secrets manager; the sealed format's version lets both coexist. - **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. @@ -556,6 +556,8 @@ Every agent member, whatever runs it (a Hermes or OpenClaw gateway, Claude Code, - **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. - **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. +- **Integrations: a service's own agent, without a model** *(2026-10-03)*. A service people already use from Slack (PostHog, Cal.com) gets an agent of its own kind and connection (`posthog`, `cal`), added with the org's key for the service like any platform agent (above). As in the services' Slack apps, nothing interprets the request: a mention is a command (`@Cal today`, `@PostHog count signup 30`), the connector calls the service's API with the stored key, and the answer is the reply; `help`, or an unknown command, answers with the list. A command is one call and cannot be stopped midway, and one that a restart interrupts fails with "check, then mention me again" rather than run twice, since a booking made twice is worse than one asked for again. Everything but the client, the commands and the alert wording is shared (`src/connectors/commands.ts`), so another service is one module. Conversational requests, link previews of the service's private pages, and buttons on its replies are not built: the first needs a model on Harbor's side, the other two a counterpart in Spaces (above). +- **Alerts: a service posts through its agent** *(2026-10-03)*. The owner of a platform agent points its alerts at a space both of them belong to, and gets a secret address once, to paste into the service's webhook settings; setting it again replaces the secret, and the owner or an admin turns it off. The service posts JSON there without a bearer (the secret is the credential, kept as a hash like an agent key); the agent's connector says what it means, and the agent posts that in the space as itself, so it reads, notifies and is searched like any message. An event the connector has no words for is not posted. One space per agent; a second space is a second agent. - **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/api.ts b/apps/harbor/packages/protocol/src/api.ts index 83cbf2238..7cd0972eb 100644 --- a/apps/harbor/packages/protocol/src/api.ts +++ b/apps/harbor/packages/protocol/src/api.ts @@ -12,7 +12,7 @@ import { ReadAssetResult, RestoreAssetResult, } from './changeset.js'; -import { ActingMode, AgentCredential, AgentKey, AgentKeySecret, AgentListing, Attribution, Member, Membership, Message, ReactionEmoji, Space, SpaceKind, SpaceVisibility, Topic } from './core.js'; +import { ActingMode, AgentCredential, AgentHook, AgentKey, AgentKeySecret, AgentListing, Attribution, Member, Membership, Message, ReactionEmoji, Space, SpaceKind, SpaceVisibility, Topic } from './core.js'; import { AssetId, AssetPath, BlobHash, ChangeSetId, MemberId, MessageId, SpaceId, StreamOffset, TopicId } from './ids.js'; import { AcceptInvite, @@ -461,6 +461,38 @@ export const routes = { request: z.object({ defaults: InvocationOptionValues }), response: z.object({ defaults: InvocationOptionValues }), }, + /** + * Point a platform agent's alerts at a space (spec §8 Alerts, 2026-10-03): + * its owner only, a space the agent is a member of. Mints a new secret + * address every time, shown this once (the old one stops working); pasted + * into the service's webhook settings. Render face only, like keys. + */ + setAgentHook: { + method: 'PUT', + path: '/v1/agents/:agentId/hook', + params: z.object({ agentId: MemberId }), + request: z.object({ spaceId: SpaceId }), + response: z.object({ hook: AgentHook, url: z.string().url() }), + }, + /** Turn a platform agent's alerts off: its address stops working. Owner only. Idempotent. */ + clearAgentHook: { + method: 'DELETE', + path: '/v1/agents/:agentId/hook', + params: z.object({ agentId: MemberId }), + response: z.object({}), + }, + /** + * The service's webhook delivery (spec §8 Alerts, 2026-10-03). Pre-auth: + * the secret in the address is the credential. Any JSON body; the agent's + * connector says what it means, and `posted` is false for an event it + * leaves unsaid. + */ + receiveAgentHook: { + method: 'POST', + path: '/v1/hooks/:agentId/:token', // pre-auth allowed + params: z.object({ agentId: MemberId, token: z.string().min(1).max(128) }), + response: z.object({ posted: z.boolean() }), + }, /** A space's invocations, newest first (a thread's, with threadRootId): the working indicators and refused lines. */ listInvocations: { method: 'GET', diff --git a/apps/harbor/packages/protocol/src/core.ts b/apps/harbor/packages/protocol/src/core.ts index 6b936b7e9..cb1c4cc44 100644 --- a/apps/harbor/packages/protocol/src/core.ts +++ b/apps/harbor/packages/protocol/src/core.ts @@ -70,9 +70,12 @@ export const AGENT_PAIRS: ReadonlyArray<{ kind: string; connection: string }> = { kind: 'custom', connection: 'contract' }, { kind: 'hermes', connection: 'plugin' }, ...REPLICAS_CODING_AGENTS.map((kind) => ({ kind, connection: 'replicas' })), + // Integrations (2026-10-03): the service's own API, called on a command, its kind and connection one name. + { kind: 'posthog', connection: 'posthog' }, + { kind: 'cal', connection: 'cal' }, ]; /** Connections whose connector Harbor runs, calling the platform with a credential it holds (spec §8 Connectors). */ -export const HARBOR_RUN_CONNECTIONS: readonly string[] = ['replicas']; +export const HARBOR_RUN_CONNECTIONS: readonly string[] = ['replicas', 'posthog', 'cal']; export function isAgentPair(kind: string, connection: string): boolean { return AGENT_PAIRS.some((pair) => pair.kind === kind && pair.connection === connection); @@ -112,8 +115,20 @@ export const AgentCredential = z.object({ }); export type AgentCredential = z.infer; -/** An agent with its keys, as the Agents screen lists them, and its platform credential when Harbor runs its connector. */ -export const AgentListing = z.object({ agent: Member, keys: z.array(AgentKey), credential: AgentCredential.optional() }); +/** + * Where a platform agent's alerts land (spec §8 Alerts, 2026-10-03): the + * service posts to a secret address, and the agent posts what it says into + * this space. The address is shown once, when it is set; the org keeps its hash. + */ +export const AgentHook = z.object({ + spaceId: SpaceId, + setBy: MemberId, + setAt: z.iso.datetime(), +}); +export type AgentHook = z.infer; + +/** An agent with its keys, as the Agents screen lists them, and its platform credential and alert hook when Harbor runs its connector. */ +export const AgentListing = z.object({ agent: Member, keys: z.array(AgentKey), credential: AgentCredential.optional(), hook: AgentHook.optional() }); export type AgentListing = z.infer; /** diff --git a/apps/harbor/packages/server/src/connectors/cal/index.ts b/apps/harbor/packages/server/src/connectors/cal/index.ts new file mode 100644 index 000000000..d571cf758 --- /dev/null +++ b/apps/harbor/packages/server/src/connectors/cal/index.ts @@ -0,0 +1,269 @@ +import type { ConnectorPlatform } from '../platforms.js'; +import { + dateInZone, + formatWhen, + IntegrationError, + integrationPlatform, + nextDate, + requestJson, + UsageError, + zonedToUtc, + type Command, + type Integration, +} from '../commands.js'; + +// The Cal.com integration (spec §8 Integrations, 2026-10-03): an agent on a +// Cal.com API key (`cal_live_…`), acting as the key's owner, in their time +// zone. API v2; each endpoint pins the cal-api-version its docs name +// (checked 2026-10-03). + +export const CAL_API = 'https://api.cal.com/v2'; +const VERSIONS = { bookings: '2026-05-01', createBooking: '2026-02-25', cancel: '2026-02-25', eventTypes: '2024-06-14', slots: '2024-09-04' }; + +interface Me { + id: number; + username: string | null; + email: string; + timeZone: string; +} +interface Booking { + uid: string; + title: string; + start: string; + end?: string; + status?: string; + location?: string | null; + attendees?: { name?: string; email?: string }[]; +} +interface EventType { + id: number; + slug: string; + title: string; + lengthInMinutes?: number; +} + +export class CalApi { + private me?: Promise; + + constructor( + private readonly key: string, + private readonly base: string = CAL_API, + ) {} + + private call(path: string, version: string | undefined, init: RequestInit = {}): Promise { + const headers: Record = { authorization: `Bearer ${this.key}`, 'content-type': 'application/json' }; + if (version) headers['cal-api-version'] = version; + return requestJson(`${this.base}${path}`, { ...init, headers }).then((body) => (body as { data?: unknown })?.data); + } + + profile(): Promise { + this.me ??= (this.call('/me', undefined) as Promise).catch((err) => { + this.me = undefined; + throw err; + }); + return this.me; + } + + async bookings(query: { status?: string; afterStart?: string; beforeEnd?: string; limit?: number }): Promise { + const q = new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined).map(([k, v]) => [k, String(v)])); + return ((await this.call(`/bookings?${q}`, VERSIONS.bookings)) as Booking[] | undefined) ?? []; + } + + async eventTypes(): Promise { + const me = await this.profile(); + const q = me.username ? `?username=${encodeURIComponent(me.username)}` : ''; + return ((await this.call(`/event-types${q}`, VERSIONS.eventTypes)) as EventType[] | undefined) ?? []; + } + + async eventType(slug: string): Promise { + const types = await this.eventTypes(); + const found = types.find((t) => t.slug.toLowerCase() === slug.toLowerCase()); + if (!found) throw new UsageError(`There's no event type "${slug}". Yours: ${types.map((t) => t.slug).join(', ') || 'none'}.`); + return found; + } + + async slots(eventTypeId: number, start: string, end: string, timeZone: string): Promise> { + const q = new URLSearchParams({ eventTypeId: String(eventTypeId), start, end, timeZone }); + return ((await this.call(`/slots?${q}`, VERSIONS.slots)) as Record | undefined) ?? {}; + } + + async book(input: { eventTypeId: number; start: string; attendee: { name: string; email: string; timeZone: string } }): Promise { + return (await this.call('/bookings', VERSIONS.createBooking, { method: 'POST', body: JSON.stringify(input) })) as Booking; + } + + async cancel(uid: string, reason?: string): Promise { + await this.call(`/bookings/${encodeURIComponent(uid)}/cancel`, VERSIONS.cancel, { + method: 'POST', + body: JSON.stringify(reason ? { cancellationReason: reason } : {}), + }); + } +} + +function bookingLine(b: Booking, timeZone: string): string { + const who = (b.attendees ?? []).map((a) => a.name || a.email).filter(Boolean).join(', '); + const where = b.location && /^https?:\/\//.test(b.location) ? ` · [join](${b.location})` : ''; + return `- **${formatWhen(b.start, timeZone)}**: ${b.title}${who ? ` with ${who}` : ''}${where} · \`${b.uid}\``; +} + +/** The bookings starting on these dates (YYYY-MM-DD, in the owner's zone), soonest first. */ +async function bookingsOn(api: CalApi, from: string, toExclusive: string, empty: string, heading: string): Promise { + const me = await api.profile(); + const afterStart = zonedToUtc(`${from}T00:00:00`, me.timeZone).toISOString(); + const beforeEnd = zonedToUtc(`${toExclusive}T00:00:00`, me.timeZone).toISOString(); + const bookings = (await api.bookings({ status: 'upcoming', afterStart, beforeEnd, limit: 50 })) + .filter((b) => b.start < beforeEnd) + .sort((a, b) => a.start.localeCompare(b.start)); + if (bookings.length === 0) return empty; + return [heading, ...bookings.slice(0, 20).map((b) => bookingLine(b, me.timeZone))].join('\n'); +} + +const today = (tz: string) => dateInZone(new Date(), tz); +const DATE = /^\d{4}-\d{2}-\d{2}$/; + +const commands: Command[] = [ + { + name: 'today', + summary: "today's bookings", + async run(api) { + const { timeZone } = await api.profile(); + const d = today(timeZone); + return bookingsOn(api, d, nextDate(d), 'Nothing booked today.', 'Today:'); + }, + }, + { + name: 'tomorrow', + summary: "tomorrow's bookings", + async run(api) { + const { timeZone } = await api.profile(); + const d = nextDate(today(timeZone)); + return bookingsOn(api, d, nextDate(d), 'Nothing booked tomorrow.', 'Tomorrow:'); + }, + }, + { + name: 'upcoming', + aliases: ['week'], + summary: 'bookings in the next 7 days', + async run(api) { + const { timeZone } = await api.profile(); + const d = today(timeZone); + return bookingsOn(api, d, nextDate(d, 7), 'Nothing booked in the next 7 days.', 'Next 7 days:'); + }, + }, + { + name: 'links', + aliases: ['types'], + summary: 'your event types and their booking links', + async run(api) { + const [me, types] = await Promise.all([api.profile(), api.eventTypes()]); + if (types.length === 0) return 'No event types yet.'; + return types + .map((t) => `- **${t.title}** (\`${t.slug}\`${t.lengthInMinutes ? `, ${t.lengthInMinutes} min` : ''})${me.username ? `: https://cal.com/${me.username}/${t.slug}` : ''}`) + .join('\n'); + }, + }, + { + name: 'slots', + aliases: ['free', 'availability'], + args: ' [YYYY-MM-DD]', + summary: 'open times for an event type on a day (default today)', + async run(api, args) { + const [slug, date] = args.split(' ').filter(Boolean); + if (!slug) throw new UsageError('Which event type? `links` lists them.'); + const { timeZone } = await api.profile(); + const day = date ?? today(timeZone); + if (!DATE.test(day)) throw new UsageError(`"${day}" isn't a date like 2026-10-05.`); + const type = await api.eventType(slug); + const slots = await api.slots(type.id, day, nextDate(day), timeZone); + const times = Object.values(slots).flat().filter((s) => dateInZone(new Date(s.start), timeZone) === day); + if (times.length === 0) return `No open times for ${type.title} on ${day}.`; + const shown = times.slice(0, 24).map((s) => new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', timeZone }).format(new Date(s.start))); + return `**${type.title}** on ${day} (${timeZone}): ${shown.join(', ')}${times.length > 24 ? ', …' : ''}`; + }, + }, + { + name: 'book', + args: ' [name]', + summary: "book someone in (the time is in the key owner's time zone unless it names its own)", + async run(api, args) { + const [slug, when, email, ...name] = args.split(' ').filter(Boolean); + if (!slug || !when || !email) throw new UsageError('I need the event type, the time and the attendee’s email.'); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new UsageError(`"${email}" isn't an email address.`); + const { timeZone } = await api.profile(); + const own = /(Z|[+-]\d{2}:?\d{2})$/i.test(when); + const start = own ? new Date(when) : zonedToUtc(when.length === 16 ? `${when}:00` : when, timeZone); + if (Number.isNaN(start.getTime())) throw new UsageError(`"${when}" isn't a time like 2026-10-05T15:00.`); + const type = await api.eventType(slug); + const booking = await api.book({ + eventTypeId: type.id, + start: start.toISOString(), + attendee: { name: name.join(' ') || email.split('@')[0]!, email, timeZone }, + }); + return `Booked: **${booking.title ?? type.title}**, ${formatWhen(booking.start ?? start.toISOString(), timeZone)} with ${email} · \`${booking.uid}\``; + }, + }, + { + name: 'cancel', + args: ' [reason]', + summary: 'cancel a booking (its id is in `today` and `upcoming`)', + async run(api, args) { + const [uid, ...reason] = args.split(' ').filter(Boolean); + if (!uid) throw new UsageError('Which booking?'); + await api.cancel(uid.replace(/`/g, ''), reason.join(' ') || undefined); + return `Cancelled \`${uid.replace(/`/g, '')}\`.`; + }, + }, +]; + +const TRIGGERS: Record = { + BOOKING_CREATED: '📅 New booking', + BOOKING_REQUESTED: '🙋 Booking request', + BOOKING_RESCHEDULED: '🔁 Rescheduled', + BOOKING_CANCELLED: '❌ Cancelled', + BOOKING_REJECTED: '🚫 Declined', + BOOKING_PAID: '💳 Paid', + BOOKING_NO_SHOW_UPDATED: '👻 No-show', + MEETING_STARTED: '▶️ Meeting started', + MEETING_ENDED: '⏹️ Meeting ended', +}; + +/** + * A Cal.com webhook (checked against its payload reference, 2026-10-03): + * `{ triggerEvent, payload }`, except the MEETING_* triggers, whose booking + * fields sit at the top level. PING is the "test" button in Cal.com's + * webhook settings. + */ +export function calAlert(body: unknown): string | undefined { + if (!body || typeof body !== 'object') return undefined; + const b = body as Record; + const trigger = typeof b.triggerEvent === 'string' ? b.triggerEvent : undefined; + if (!trigger) return undefined; + if (trigger === 'PING') return '✅ Cal.com is connected: bookings will show up here.'; + const p = (b.payload && typeof b.payload === 'object' ? b.payload : b) as Record; + const organizer = (p.organizer && typeof p.organizer === 'object' ? p.organizer : {}) as { timeZone?: string }; + const attendees = Array.isArray(p.attendees) ? (p.attendees as { name?: string; email?: string }[]) : []; + const who = attendees.map((a) => a.name || a.email).filter(Boolean).join(', '); + const title = typeof p.title === 'string' ? p.title : 'a booking'; + const when = typeof p.startTime === 'string' ? formatWhen(p.startTime, organizer.timeZone) : undefined; + const label = TRIGGERS[trigger] ?? trigger.toLowerCase().replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase()); + const lines = [`${label}: **${title}**${when ? `, ${when}` : ''}${who ? ` with ${who}` : ''}`]; + const reason = p.cancellationReason ?? p.rejectionReason; + if (typeof reason === 'string' && reason) lines.push(`> ${reason.slice(0, 500)}`); + return lines.join('\n'); +} + +export function calIntegration(base = CAL_API): Integration { + return { + label: 'Cal.com', + client: (secret) => new CalApi(secret.trim(), base), + async check(api) { + const me = await api.profile(); + if (!me?.timeZone) throw new IntegrationError('Cal.com returned no profile for this key'); + }, + commands, + alert: calAlert, + }; +} + +export function calPlatform(base = CAL_API): ConnectorPlatform { + return integrationPlatform(calIntegration(base)); +} diff --git a/apps/harbor/packages/server/src/connectors/commands.ts b/apps/harbor/packages/server/src/connectors/commands.ts new file mode 100644 index 000000000..3b3fb6bf3 --- /dev/null +++ b/apps/harbor/packages/server/src/connectors/commands.ts @@ -0,0 +1,273 @@ +import type { ConnectorCapabilities, Invocation, ServerFrame } from '@rowboat/spaces-protocol'; +import { HarborError } from '../errors.js'; +import type { ConnectorEnv, ConnectorPlatform, RunningConnector } from './platforms.js'; + +// Integrations (spec §8 Integrations, 2026-10-03): an agent for a service +// people already use from Slack, such as PostHog or Cal.com, run by Harbor +// on the org's key for that service. No model in the loop, as in their Slack +// apps: a mention is a command ("@Cal today"), the connector calls the +// service's API, and its answer is the reply. What differs per service is an +// Integration (its client, its commands, what its alerts say); everything +// else is here, so a new service is one module beside this file. + +const LIST_EVERY_MS = 60_000; + +/** A service's API said no. 401 and 403 mean the key; anything else is the call. */ +export class IntegrationError extends Error { + constructor( + message: string, + readonly status?: number, + ) { + super(message); + } + get rejectsKey(): boolean { + return this.status === 401 || this.status === 403; + } +} + +/** The person asked for something the command can't do as asked: said back to them, not a failure. */ +export class UsageError extends Error {} + +export interface Command { + name: string; + aliases?: readonly string[]; + /** What follows the command word, for help: ` [days]`. */ + args?: string; + summary: string; + /** The reply's Markdown. `args` is the rest of the message after the command word. */ + run(api: Api, args: string): Promise; +} + +export interface Integration { + /** The service's name, as people know it. */ + label: string; + /** A client on this key. Cheap: no call is made until a command runs. */ + client(secret: string): Api; + /** Check the key with the service before Harbor saves it; throws IntegrationError on refusal. */ + check(api: Api): Promise; + commands: readonly Command[]; + /** What an alert from the service says (spec §8 Alerts); undefined for an event left unsaid. */ + alert?(payload: unknown): string | undefined; +} + +export function integrationPlatform(integration: Integration): ConnectorPlatform { + return { + async verify(secret) { + try { + await integration.check(integration.client(secret)); + } catch (err) { + if (err instanceof IntegrationError && err.rejectsKey) throw new HarborError('invalid_request', `${integration.label} did not accept this key: ${err.message}`); + throw new HarborError('invalid_request', `${integration.label} could not check this key: ${(err as Error).message}`); + } + }, + start(env) { + return new CommandConnector(env, integration).start(); + }, + ...(integration.alert ? { alert: integration.alert } : {}), + }; +} + +/** A JSON call to a service: its body, or an IntegrationError carrying the service's own message. */ +export async function requestJson(url: string, init: RequestInit & { timeoutMs?: number } = {}): Promise { + let res: Response; + try { + res = await fetch(url, { ...init, signal: AbortSignal.timeout(init.timeoutMs ?? 20_000) }); + } catch (err) { + throw new IntegrationError(`could not reach ${new URL(url).host}: ${(err as Error).message}`); + } + const text = await res.text(); + let body: unknown; + try { + body = text ? JSON.parse(text) : undefined; + } catch { + body = undefined; + } + if (!res.ok) throw new IntegrationError(errorMessage(body) ?? `${res.status} ${res.statusText}`.trim(), res.status); + return body; +} + +function errorMessage(body: unknown): string | undefined { + if (!body || typeof body !== 'object') return undefined; + const b = body as Record; + const nested = b.error && typeof b.error === 'object' ? (b.error as Record).message : undefined; + const found = [b.detail, b.message, nested, b.error].find((v) => typeof v === 'string' && v.length > 0); + return typeof found === 'string' ? found.slice(0, 300) : undefined; +} + +/** The person's words: the agent's own mention taken out, anyone else's kept as their name. */ +export function commandText(body: string, agentId: string): string { + return body + .replace(/\[@([^\]]*)\]\(#member:([^)]*)\)/g, (_all, name: string, id: string) => (id === agentId ? ' ' : `@${name}`)) + .replace(/\s+/g, ' ') + .trim(); +} + +export class CommandConnector implements RunningConnector { + private readonly running = new Map>(); + private readonly timers: NodeJS.Timeout[] = []; + private unsubscribe: (() => void) | undefined; + private stopped = false; + + constructor( + private readonly env: ConnectorEnv, + private readonly integration: Integration, + ) {} + + start(): this { + this.unsubscribe = this.env.subscribe((frame) => this.onFrame(frame)); + void this.boot(); + this.timers.push(setInterval(() => void this.list(), LIST_EVERY_MS)); + return this; + } + + async stop(): Promise { + this.stopped = true; + this.unsubscribe?.(); + for (const timer of this.timers) clearInterval(timer); + await Promise.allSettled([...this.running.values()]); + } + + private async boot(): Promise { + // A command takes no options and can't be stopped midway: it is one call. + const capabilities: ConnectorCapabilities = { stop: false, options: [] }; + await this.safe(() => this.env.service.declareCapabilities(this.env.ctx, capabilities), undefined); + for (const invocation of await this.safe(() => this.env.service.listAgentInvocations(this.env.ctx), [])) { + // One a previous run took on may or may not have reached the service; running it again + // could book a meeting twice, so it ends and the person asks again. + if (invocation.state === 'working' && !this.running.has(invocation.id)) { + await this.fail(invocation, 'Harbor restarted while this ran, so it may or may not have happened. Check, then mention me again.'); + } else if (invocation.state === 'pending') this.intake(invocation); + } + } + + private onFrame(frame: ServerFrame): void { + if (frame.kind === 'invocation') this.intake(frame.invocation); + } + + private async list(): Promise { + for (const invocation of await this.safe(() => this.env.service.listAgentInvocations(this.env.ctx), [])) { + if (invocation.state === 'pending') this.intake(invocation); + } + } + + private intake(invocation: Invocation): void { + if (this.stopped || invocation.state !== 'pending' || this.running.has(invocation.id)) return; + const done = (async () => { + try { + await this.env.service.acknowledgeInvocation(this.env.ctx, invocation.id); + } catch { + return; // cancelled on its way, or someone else took it + } + await this.run(invocation); + })() + .catch((err) => this.env.log('invocation failed unexpectedly', { id: invocation.id, error: (err as Error).message })) + .finally(() => this.running.delete(invocation.id)); + this.running.set(invocation.id, done); + } + + private async run(invocation: Invocation): Promise { + const text = commandText(invocation.trigger.body, this.env.agent.id); + const [word = '', ...rest] = text.split(' '); + const name = word.toLowerCase(); + if (!name || name === 'help') return this.answer(invocation, this.help()); + const command = this.integration.commands.find((c) => c.name === name || c.aliases?.includes(name)); + if (!command) return this.answer(invocation, `I don't know "${word.slice(0, 40)}".\n\n${this.help()}`); + + await this.safe(() => this.env.service.updateInvocation(this.env.ctx, invocation.id, { state: 'working', activity: `${this.integration.label}: ${command.name}` }), undefined); + let reply: string; + try { + reply = await command.run(this.integration.client(await this.env.credential()), rest.join(' ')); + } catch (err) { + if (err instanceof UsageError) return this.answer(invocation, `${err.message}\n\nUsage: \`${this.usage(command)}\``); + if (err instanceof IntegrationError && err.rejectsKey) { + await this.env.rejectCredential(`${this.integration.label} refused the key: ${err.message}`); + return this.fail(invocation, `${this.integration.label} did not accept my key (${err.message}). Its owner needs to replace the key in Agents.`); + } + if (err instanceof IntegrationError || err instanceof HarborError) return this.fail(invocation, `${this.integration.label}: ${err.message}`); + throw err; + } + await this.answer(invocation, reply); + } + + private help(): string { + const lines = this.integration.commands.map((c) => `- \`${this.usage(c)}\`: ${c.summary}`); + return [`Mention me with one of these:`, ...lines, `- \`@${this.env.agent.displayName} help\`: this list`].join('\n'); + } + + private usage(command: Command): string { + return `@${this.env.agent.displayName} ${command.name}${command.args ? ` ${command.args}` : ''}`; + } + + private async answer(invocation: Invocation, body: string): Promise { + await this.safe(() => this.env.service.answerInvocation(this.env.ctx, invocation.id, body.slice(0, 60_000)), undefined); + } + + private async fail(invocation: Invocation, error: string): Promise { + await this.safe(() => this.env.service.updateInvocation(this.env.ctx, invocation.id, { state: 'failed', error: error.slice(0, 1000) }), undefined); + } + + private async safe(fn: () => Promise, fallback: T): Promise { + try { + return await fn(); + } catch (err) { + this.env.log('call failed', { error: (err as Error).message }); + return fallback; + } + } +} + +// --- formatting shared by integrations --------------------------------------------------- + +/** A Markdown table, cells flattened to one line and capped. */ +export function markdownTable(columns: readonly string[], rows: readonly (readonly unknown[])[]): string { + const cell = (v: unknown) => { + const s = v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v); + return s.replace(/\|/g, '\\|').replace(/\s+/g, ' ').slice(0, 80); + }; + return [`| ${columns.map(cell).join(' | ')} |`, `| ${columns.map(() => '---').join(' | ')} |`, ...rows.map((r) => `| ${r.map(cell).join(' | ')} |`)].join('\n'); +} + +/** "Mon, Oct 5, 3:00 PM PDT" in the given zone (UTC when it is unknown). */ +export function formatWhen(iso: string, timeZone?: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + const options: Intl.DateTimeFormatOptions = { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }; + try { + return new Intl.DateTimeFormat('en-US', { ...options, timeZone: timeZone || 'UTC' }).format(date); + } catch { + return new Intl.DateTimeFormat('en-US', { ...options, timeZone: 'UTC' }).format(date); + } +} + +/** A zone's offset from UTC at an instant, in minutes (positive east of Greenwich). */ +export function zoneOffsetMinutes(timeZone: string, at: Date): number { + const parts = Object.fromEntries( + new Intl.DateTimeFormat('en-US', { timeZone, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' }) + .formatToParts(at) + .map((p) => [p.type, p.value]), + ); + const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second)); + return Math.round((asUtc - Math.floor(at.getTime() / 1000) * 1000) / 60_000); +} + +/** The instant a wall-clock time in a zone names: `2026-10-05T15:00` in America/New_York → 19:00Z. */ +export function zonedToUtc(wall: string, timeZone: string): Date { + const naive = new Date(`${wall}Z`); + if (Number.isNaN(naive.getTime())) return naive; + // Twice: the offset at the guess can differ from the offset at the answer across a DST change. + let at = new Date(naive.getTime() - zoneOffsetMinutes(timeZone, naive) * 60_000); + at = new Date(naive.getTime() - zoneOffsetMinutes(timeZone, at) * 60_000); + return at; +} + +/** The calendar date (YYYY-MM-DD) an instant falls on in a zone. */ +export function dateInZone(at: Date, timeZone: string): string { + return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(at); +} + +/** The date after this one, both YYYY-MM-DD. */ +export function nextDate(date: string, days = 1): string { + const d = new Date(`${date}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} diff --git a/apps/harbor/packages/server/src/connectors/host.ts b/apps/harbor/packages/server/src/connectors/host.ts index e8b374c2c..5a6d1f967 100644 --- a/apps/harbor/packages/server/src/connectors/host.ts +++ b/apps/harbor/packages/server/src/connectors/host.ts @@ -35,6 +35,11 @@ export class HostedConnectors { return credential; } + /** The agent's platform's words for an alert, or undefined when it says nothing (spec §8 Alerts). */ + alert(connection: string, payload: unknown): string | undefined { + return PLATFORMS[connection]?.alert?.(payload); + } + async startAll(): Promise { for (const agent of await this.deps.store.listAgentsByConnection(HARBOR_RUN_CONNECTIONS)) this.ensure(agent); } diff --git a/apps/harbor/packages/server/src/connectors/platforms.ts b/apps/harbor/packages/server/src/connectors/platforms.ts index a5dc9511c..8d07168cf 100644 --- a/apps/harbor/packages/server/src/connectors/platforms.ts +++ b/apps/harbor/packages/server/src/connectors/platforms.ts @@ -1,6 +1,8 @@ import type { Member, ServerFrame } from '@rowboat/spaces-protocol'; import type { ActorCtx } from '../core/kernel.js'; import type { HarborService } from '../service.js'; +import { calPlatform } from './cal/index.js'; +import { posthogPlatform } from './posthog/index.js'; import { replicasPlatform } from './replicas/index.js'; // Connectors Harbor runs (spec §8 Connectors, 2026-09-30): for an agent whose @@ -45,9 +47,17 @@ export interface ConnectorPlatform { verify(secret: string): Promise; /** Run the connector for one agent until stopped. */ start(env: ConnectorEnv): RunningConnector; + /** + * What the agent says about an alert its platform sent to its hook (spec §8 + * Alerts, 2026-10-03): the message to post, or undefined for an event it + * leaves unsaid. Absent = the platform sends none. + */ + alert?(payload: unknown): string | undefined; } /** One entry per connection in HARBOR_RUN_CONNECTIONS. */ export const PLATFORMS: Record = { replicas: replicasPlatform(), + posthog: posthogPlatform(), + cal: calPlatform(), }; diff --git a/apps/harbor/packages/server/src/connectors/posthog/index.ts b/apps/harbor/packages/server/src/connectors/posthog/index.ts new file mode 100644 index 000000000..a6c8847e1 --- /dev/null +++ b/apps/harbor/packages/server/src/connectors/posthog/index.ts @@ -0,0 +1,216 @@ +import type { ConnectorPlatform } from '../platforms.js'; +import { IntegrationError, integrationPlatform, markdownTable, requestJson, UsageError, type Command, type Integration } from '../commands.js'; + +// The PostHog integration (spec §8 Integrations, 2026-10-03): an agent on a +// personal API key, answering from the key's current project. The key says +// nothing of its region, so the client asks US Cloud, then EU Cloud; a +// self-hosted PostHog is named in front of the key: `https://ph.example.com phx_…`. +// The key needs these read scopes: user, query, insight, feature_flag. + +export const POSTHOG_CLOUDS = ['https://us.posthog.com', 'https://eu.posthog.com']; +const MAX_ROWS = 20; + +interface Project { + host: string; + id: number; + name: string; +} + +export class PostHogApi { + private project?: Promise; + + constructor( + private readonly key: string, + private readonly hosts: readonly string[], + ) {} + + /** From a credential: the key, or a self-hosted address and the key. */ + static fromCredential(secret: string, clouds: readonly string[] = POSTHOG_CLOUDS): PostHogApi { + const [first, second] = secret.trim().split(/\s+/); + if (second && /^https?:\/\//.test(first!)) return new PostHogApi(second, [first!.replace(/\/+$/, '')]); + return new PostHogApi(first ?? '', clouds); + } + + /** The key's current project, and the PostHog that answered for it. */ + current(): Promise { + this.project ??= this.find().catch((err) => { + this.project = undefined; + throw err; + }); + return this.project; + } + + private async find(): Promise { + let refusal: IntegrationError | undefined; + for (const host of this.hosts) { + try { + const me = (await requestJson(`${host}/api/users/@me/`, { headers: this.headers() })) as { team?: { id?: number; name?: string } }; + if (typeof me?.team?.id !== 'number') throw new IntegrationError('this key has no current project'); + return { host, id: me.team.id, name: me.team.name ?? `Project ${me.team.id}` }; + } catch (err) { + // A key is refused by every region but its own. + if (err instanceof IntegrationError && err.rejectsKey) refusal = err; + else throw err; + } + } + throw refusal ?? new IntegrationError('no PostHog to ask'); + } + + private headers(): Record { + return { authorization: `Bearer ${this.key}`, 'content-type': 'application/json' }; + } + + async get(path: string): Promise { + const p = await this.current(); + return requestJson(`${p.host}/api/projects/${p.id}${path}`, { headers: this.headers() }); + } + + /** A HogQL query's columns and rows. */ + async query(hogql: string): Promise<{ columns: string[]; results: unknown[][] }> { + const p = await this.current(); + const body = (await requestJson(`${p.host}/api/projects/${p.id}/query/`, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ query: { kind: 'HogQLQuery', query: hogql }, name: 'rowboat' }), + timeoutMs: 30_000, + })) as { columns?: string[]; results?: unknown[][] }; + return { columns: body?.columns ?? [], results: body?.results ?? [] }; + } + + async link(path: string): Promise { + const p = await this.current(); + return `${p.host}/project/${p.id}${path}`; + } +} + +/** A string literal in HogQL. */ +export function hogqlString(value: string): string { + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +} + +function days(arg: string | undefined, fallback: number): number { + if (!arg) return fallback; + const n = Number(arg.replace(/d$/i, '')); + if (!Number.isInteger(n) || n < 1 || n > 365) throw new UsageError(`"${arg}" isn't a number of days from 1 to 365.`); + return n; +} + +const fmt = (n: unknown) => (typeof n === 'number' ? n.toLocaleString('en-US') : String(n ?? 0)); + +const commands: Command[] = [ + { + name: 'count', + args: ' [days]', + summary: 'how many times an event happened, and to how many people (default 7 days)', + async run(api, args) { + const parts = args.split(' ').filter(Boolean); + const last = parts.length > 1 && /^\d+d?$/i.test(parts[parts.length - 1]!) ? parts.pop() : undefined; + const event = parts.join(' '); + if (!event) throw new UsageError('Which event?'); + const n = days(last, 7); + const { results } = await api.query( + `select count(), count(distinct person_id) from events where event = ${hogqlString(event)} and timestamp > now() - interval ${n} day`, + ); + const [total, people] = results[0] ?? [0, 0]; + return `**${event}**, last ${n} day${n === 1 ? '' : 's'}: ${fmt(total)} times, ${fmt(people)} people.`; + }, + }, + { + name: 'top', + aliases: ['events'], + args: '[days]', + summary: 'the most frequent events (default 7 days)', + async run(api, args) { + const n = days(args.trim() || undefined, 7); + const { results } = await api.query( + `select event, count() as total from events where timestamp > now() - interval ${n} day group by event order by total desc limit 10`, + ); + if (results.length === 0) return `No events in the last ${n} days.`; + return `Top events, last ${n} days:\n\n${markdownTable(['Event', 'Count'], results.map(([e, c]) => [e, fmt(c)]))}`; + }, + }, + { + name: 'query', + aliases: ['sql', 'hogql'], + args: '', + summary: 'run a HogQL query and show the first rows', + async run(api, args) { + const hogql = args.replace(/^`+|`+$/g, '').trim(); + if (!hogql) throw new UsageError('Which query?'); + const { columns, results } = await api.query(hogql); + if (results.length === 0) return 'No rows.'; + const more = results.length > MAX_ROWS ? `\n\nFirst ${MAX_ROWS} of ${results.length} rows.` : ''; + return markdownTable(columns.length ? columns : results[0]!.map((_, i) => `col${i + 1}`), results.slice(0, MAX_ROWS)) + more; + }, + }, + { + name: 'insights', + args: '[search]', + summary: 'saved insights, with links', + async run(api, args) { + const q = new URLSearchParams({ saved: 'true', limit: '10', ...(args.trim() ? { search: args.trim() } : {}) }); + const body = (await api.get(`/insights/?${q}`)) as { results?: { short_id?: string; name?: string | null; derived_name?: string | null }[] }; + const insights = body?.results ?? []; + if (insights.length === 0) return args.trim() ? `No saved insights match "${args.trim()}".` : 'No saved insights yet.'; + const lines = await Promise.all(insights.map(async (i) => `- [${i.name || i.derived_name || i.short_id}](${await api.link(`/insights/${i.short_id}`)})`)); + return lines.join('\n'); + }, + }, + { + name: 'flags', + args: '[search]', + summary: 'feature flags and whether they are on', + async run(api, args) { + const q = new URLSearchParams({ limit: '20', ...(args.trim() ? { search: args.trim() } : {}) }); + const body = (await api.get(`/feature_flags/?${q}`)) as { results?: { id: number; key: string; name?: string; active?: boolean }[] }; + const flags = body?.results ?? []; + if (flags.length === 0) return args.trim() ? `No feature flags match "${args.trim()}".` : 'No feature flags yet.'; + const lines = await Promise.all(flags.map(async (f) => `- ${f.active ? '🟢' : '⚪'} [\`${f.key}\`](${await api.link(`/feature_flags/${f.id}`)})${f.name ? `: ${f.name}` : ''}`)); + return lines.join('\n'); + }, + }, +]; + +/** + * An alert from a PostHog webhook destination. Its body is whatever the + * destination's template says, so: a `text` or `message` the template wrote + * is said as is; an event (the default template's `{ event: {event}, + * person: {person} }`, which carries the insight alert's properties) is said + * by its name and properties; anything else, briefly, as it came. + */ +export function posthogAlert(payload: unknown): string | undefined { + if (!payload || typeof payload !== 'object') return undefined; + const p = payload as Record; + for (const key of ['text', 'message']) if (typeof p[key] === 'string' && p[key]) return (p[key] as string).slice(0, 4000); + const event = p.event && typeof p.event === 'object' ? (p.event as Record) : undefined; + if (event) { + const props = (event.properties && typeof event.properties === 'object' ? event.properties : {}) as Record; + const name = typeof event.event === 'string' ? event.event : 'event'; + const str = (k: string) => (typeof props[k] === 'string' || typeof props[k] === 'number' ? String(props[k]) : undefined); + const title = str('alert_name') ?? str('name') ?? name; + const insight = str('insight_name'); + const link = str('insight_url') ?? str('alert_url') ?? (typeof event.url === 'string' ? event.url : undefined); + const lines = [`🔔 **${title}**${insight ? ` on ${insight}` : ''}${title === name ? '' : ` (${name})`}`]; + const breaches = props.breaches; + if (Array.isArray(breaches)) lines.push(...breaches.slice(0, 5).map((b) => `- ${String(b).slice(0, 300)}`)); + if (link) lines.push(link); + return lines.join('\n'); + } + return `🔔 PostHog sent:\n\`\`\`json\n${JSON.stringify(payload, null, 2).slice(0, 1500)}\n\`\`\``; +} + +export function posthogIntegration(clouds: readonly string[] = POSTHOG_CLOUDS): Integration { + return { + label: 'PostHog', + client: (secret) => PostHogApi.fromCredential(secret, clouds), + async check(api) { + await api.current(); + }, + commands, + alert: posthogAlert, + }; +} + +export function posthogPlatform(clouds: readonly string[] = POSTHOG_CLOUDS): ConnectorPlatform { + return integrationPlatform(posthogIntegration(clouds)); +} diff --git a/apps/harbor/packages/server/src/core/agents.ts b/apps/harbor/packages/server/src/core/agents.ts index c4a5b48ae..84dc9cefc 100644 --- a/apps/harbor/packages/server/src/core/agents.ts +++ b/apps/harbor/packages/server/src/core/agents.ts @@ -1,7 +1,8 @@ -import { HARBOR_RUN_CONNECTIONS, isAgentPair, type AgentCredential, type AgentKey, type AgentKeySecret, type AgentListing, type InvocationOptionValues, type Member } from '@rowboat/spaces-protocol'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { HARBOR_RUN_CONNECTIONS, isAgentPair, type AgentCredential, type AgentHook, type AgentKey, type AgentKeySecret, type AgentListing, type InvocationOptionValues, type Member } from '@rowboat/spaces-protocol'; import { hashAgentKey, mintAgentKeySecret } from '../agent-keys.js'; import { HarborError } from '../errors.js'; -import { agentsManagedBy, canAddAgent, canCreateAgentKey, canRevokeAgentKey, enforce } from '../policy.js'; +import { agentsManagedBy, canAddAgent, canClearAgentHook, canCreateAgentKey, canRevokeAgentKey, canSetAgentHook, enforce } from '../policy.js'; import type { StoredAgentKey } from '../store.js'; import type { Kernel, ActorCtx } from './kernel.js'; import type { Spaces } from './spaces.js'; @@ -22,8 +23,13 @@ export interface AgentConnectorHooks { verify(connection: string, secret: string): Promise; save(agentId: string, secret: string, setBy: string): Promise; added(agent: Member): void; + /** What the agent's connector says about an alert its service sent (spec §8 Alerts): a message, or nothing to say. */ + alert(connection: string, payload: unknown): string | undefined; } +/** The secret in a hook's address: like an agent key, random enough that a fast hash is safe to store. */ +const HOOK_TOKEN_PREFIX = 'rbh_'; + export class Agents { private hooks: AgentConnectorHooks | undefined; @@ -54,10 +60,17 @@ export class Agents { const agents = await this.k.store.listAgents(agentsManagedBy(actor) === 'all' ? null : actor.id); const keys = await this.k.store.listAgentKeys(agents.map((a) => a.id)); const credentials = await this.k.store.listAgentCredentials(agents.map((a) => a.id)); + const hooks = await this.k.store.listAgentHooks(agents.map((a) => a.id)); return agents.map((agent) => { const stored = credentials.find((c) => c.agentId === agent.id); const { agentId: _agentId, sealed: _sealed, ...credential } = stored ?? ({} as never); - return { agent, keys: keys.filter((k) => k.agentId === agent.id), ...(stored ? { credential } : {}) }; + const hook = hooks.find((h) => h.agentId === agent.id); + return { + agent, + keys: keys.filter((k) => k.agentId === agent.id), + ...(stored ? { credential } : {}), + ...(hook ? { hook: { spaceId: hook.spaceId, setBy: hook.setBy, setAt: hook.setAt } } : {}), + }; }); } @@ -125,6 +138,46 @@ export class Agents { return defaults; } + /** + * Point a platform agent's alerts at a space (spec §8 Alerts, 2026-10-03): + * the owner, in a space they and the agent are both members of. A new + * secret every time, returned this once; the old address stops working. + */ + async setHook(ctx: ActorCtx, agentId: string, spaceId: string): Promise<{ hook: AgentHook; token: string }> { + const agent = await this.agent(agentId); + enforce(canSetAgentHook(await this.actor(ctx), agent)); + if (!HARBOR_RUN_CONNECTIONS.includes(agent.agentConnection ?? '')) throw new HarborError('invalid_request', 'only an agent Harbor reaches through a platform takes alerts'); + await this.k.requireMember(ctx, spaceId); + if (!(await this.k.store.getMembership(spaceId, agentId))) throw new HarborError('invalid_request', `add ${agent.displayName} to the space first`); + this.k.guardWrite(); + const token = HOOK_TOKEN_PREFIX + randomBytes(32).toString('base64url'); + const hook: AgentHook = { spaceId, setBy: ctx.memberId, setAt: this.k.now() }; + await this.k.store.putAgentHook({ ...hook, agentId, tokenHash: hashAgentKey(token) }); + return { hook, token }; + } + + /** Turn an agent's alerts off. Idempotent. */ + async clearHook(ctx: ActorCtx, agentId: string): Promise { + enforce(canClearAgentHook(await this.actor(ctx), await this.agent(agentId))); + this.k.guardWrite(); + await this.k.store.deleteAgentHook(agentId); + } + + /** + * An alert at an agent's address: where it goes and what the agent says, + * or nothing to say. An unknown agent and a wrong secret are the same + * not_found, so the address reveals nothing. + */ + async hookAlert(agentId: string, token: string, payload: unknown): Promise<{ spaceId: string; body: string } | undefined> { + const hook = await this.k.store.getAgentHook(agentId); + const given = Buffer.from(hashAgentKey(token), 'hex'); + if (!hook || !timingSafeEqual(given, Buffer.from(hook.tokenHash, 'hex'))) throw new HarborError('not_found', 'no such hook'); + const agent = await this.agent(agentId); + if (!this.hooks) throw new HarborError('not_found', 'no such hook'); + const body = this.hooks.alert(agent.agentConnection ?? '', payload); + return body ? { spaceId: hook.spaceId, body } : undefined; + } + /** Another key, for rotation: create, switch the agent over, revoke the old one. */ async createKey(ctx: ActorCtx, agentId: string): Promise { enforce(canCreateAgentKey(await this.actor(ctx), await this.agent(agentId))); diff --git a/apps/harbor/packages/server/src/http.ts b/apps/harbor/packages/server/src/http.ts index cebb5f6df..72603b819 100644 --- a/apps/harbor/packages/server/src/http.ts +++ b/apps/harbor/packages/server/src/http.ts @@ -17,6 +17,8 @@ type Env = { Variables: { memberId: string; agent?: boolean; identity?: AuthIden const MAX_BODY_BYTES = 2 * 1024 * 1024; /** Uploads only (raw-bytes route). 100MB across the board — dogfood decision 2026-08-24. */ const DEFAULT_MAX_BLOB_BYTES = 100 * 1024 * 1024; +/** A service's alert is a small JSON event (spec §8 Alerts, 2026-10-03). */ +const MAX_HOOK_BYTES = 256 * 1024; function parseWith(schema: S, value: unknown): z.infer { const r = schema.safeParse(value); @@ -94,6 +96,8 @@ export function buildHttpApp(deps: { // the handler runs the bind ceremony instead. app.use('/v1/*', async (c, next) => { if (c.req.path === routes.resolveInvite.path || c.req.path === '/v1/health') return next(); + // A service's alert carries no bearer: the secret in its address is the credential (spec §8 Alerts, 2026-10-03). + if (c.req.path.startsWith('/v1/hooks/')) return next(); const credentials = { authorization: c.req.header('authorization'), queryToken: new URL(c.req.url).searchParams.get('token') }; if (c.req.path === routes.acceptInvite.path) { const { identity, member } = await authenticateRequest(auth, credentials, { allowUnmapped: true }); @@ -316,6 +320,33 @@ export function buildHttpApp(deps: { return reply(c, routes.setAgentOptionDefaults.response, { defaults: await service.setAgentOptionDefaults(actor(c), agentId, input.defaults) }); }); + app.put(routes.setAgentHook.path, async (c) => { + const { agentId } = parseWith(routes.setAgentHook.params, c.req.param()); + const input = await body(c, routes.setAgentHook.request); + const { hook, token } = await service.setAgentHook(actor(c), agentId, input.spaceId); + return reply(c, routes.setAgentHook.response, { hook, url: `${publicOrigin(c)}/v1/hooks/${agentId}/${token}` }); + }); + + app.delete(routes.clearAgentHook.path, async (c) => { + const { agentId } = parseWith(routes.clearAgentHook.params, c.req.param()); + await service.clearAgentHook(actor(c), agentId); + return reply(c, routes.clearAgentHook.response, {}); + }); + + app.post(routes.receiveAgentHook.path, async (c) => { + const { agentId, token } = parseWith(routes.receiveAgentHook.params, c.req.param()); + if (Number(c.req.header('content-length') ?? '0') > MAX_HOOK_BYTES) throw new HarborError('payload_too_large', `alerts are limited to ${MAX_HOOK_BYTES} bytes`); + const text = await c.req.text(); + if (text.length > MAX_HOOK_BYTES) throw new HarborError('payload_too_large', `alerts are limited to ${MAX_HOOK_BYTES} bytes`); + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + throw new HarborError('invalid_request', 'an alert is a JSON body'); + } + return reply(c, routes.receiveAgentHook.response, { posted: await service.receiveAgentHook(agentId, token, payload) }); + }); + app.post(routes.createAgentKey.path, async (c) => { const { agentId } = parseWith(routes.createAgentKey.params, c.req.param()); return reply(c, routes.createAgentKey.response, { key: await service.createAgentKey(actor(c), agentId) }); diff --git a/apps/harbor/packages/server/src/main.ts b/apps/harbor/packages/server/src/main.ts index 914ac6146..7c0ca59bf 100644 --- a/apps/harbor/packages/server/src/main.ts +++ b/apps/harbor/packages/server/src/main.ts @@ -126,7 +126,7 @@ if (process.env.HARBOR_MODE === 'deployment') { ` blobs ${process.env.BLOBS_S3_BUCKET ? `s3 bucket ${process.env.BLOBS_S3_BUCKET}` : process.env.BLOBS_DIR ? `disk ${process.env.BLOBS_DIR}` : 'UNCONFIGURED — uploads will be refused (set BLOBS_S3_BUCKET or BLOBS_DIR)'}`, ); console.log(` internal ${process.env.HARBOR_INTERNAL_KEY ? 'GET /internal/stats (operator key set)' : 'off (set HARBOR_INTERNAL_KEY)'}`); - console.log(` platforms ${process.env.HARBOR_INTEGRATION_KEY ? 'Replicas agents can be added (sealing key set)' : 'off — Replicas agents need HARBOR_INTEGRATION_KEY'}`); + console.log(` platforms ${process.env.HARBOR_INTEGRATION_KEY ? 'Replicas, PostHog and Cal.com agents can be added (sealing key set)' : 'off — Replicas, PostHog and Cal.com agents need HARBOR_INTEGRATION_KEY'}`); console.log(` listening :${deployment.port}`); process.on('SIGTERM', () => void deployment.close().then(() => process.exit(0))); } else { diff --git a/apps/harbor/packages/server/src/migrations.ts b/apps/harbor/packages/server/src/migrations.ts index a0d5169ed..92a01e892 100644 --- a/apps/harbor/packages/server/src/migrations.ts +++ b/apps/harbor/packages/server/src/migrations.ts @@ -884,6 +884,24 @@ export const MIGRATIONS: Migration[] = [ )`, ], }, + { + // A platform agent's alert hook (spec §8 Alerts, 2026-10-03): the hash of + // the secret in its address, like agent_keys, and the space its alerts + // land in. One per agent; setting it again replaces the secret. + id: '032-agent-hooks', + statements: [ + `create table agent_hooks ( + org_id text not null, + agent_id text not null, + token_hash text not null, + space_id text not null, + set_by text not null, + set_at text not null, + primary key (org_id, agent_id), + foreign key (org_id, agent_id) references members(org_id, id) + )`, + ], + }, ]; export async function migrate(db: SqlDb): Promise { diff --git a/apps/harbor/packages/server/src/pg-store.ts b/apps/harbor/packages/server/src/pg-store.ts index c81479207..8ab6a225a 100644 --- a/apps/harbor/packages/server/src/pg-store.ts +++ b/apps/harbor/packages/server/src/pg-store.ts @@ -32,6 +32,7 @@ import { type MessageSearchRow, type Store, type StoredAgentCredential, + type StoredAgentHook, type StoredAgentKey, type StoredEvent, type StoredInvite, @@ -1745,6 +1746,28 @@ export class PgStore implements Store { ); return rows.map(rowToAgentCredential); } + async getAgentHook(agentId: string): Promise { + return (await this.listAgentHooks([agentId]))[0]; + } + async listAgentHooks(agentIds: string[]): Promise { + if (agentIds.length === 0) return []; + const rows = await this.sql.query<{ agent_id: string; token_hash: string; space_id: string; set_by: string; set_at: string }>( + 'select agent_id, token_hash, space_id, set_by, set_at from agent_hooks where org_id = $1 and agent_id = any($2::text[])', + [this.orgId, agentIds], + ); + return rows.map((r) => ({ agentId: r.agent_id, tokenHash: r.token_hash, spaceId: r.space_id, setBy: r.set_by, setAt: r.set_at })); + } + async putAgentHook(hook: StoredAgentHook): Promise { + await this.sql.query( + `insert into agent_hooks (org_id, agent_id, token_hash, space_id, set_by, set_at) values ($1, $2, $3, $4, $5, $6) + on conflict (org_id, agent_id) do update set token_hash = excluded.token_hash, space_id = excluded.space_id, + set_by = excluded.set_by, set_at = excluded.set_at`, + [this.orgId, hook.agentId, hook.tokenHash, hook.spaceId, hook.setBy, hook.setAt], + ); + } + async deleteAgentHook(agentId: string): Promise { + await this.sql.query('delete from agent_hooks where org_id = $1 and agent_id = $2', [this.orgId, agentId]); + } async putAgentCredential(credential: StoredAgentCredential): Promise { await this.sql.query( `insert into agent_connection_credentials (org_id, agent_id, sealed, hint, set_by, set_at) values ($1, $2, $3, $4, $5, $6) diff --git a/apps/harbor/packages/server/src/policy.ts b/apps/harbor/packages/server/src/policy.ts index 4128d5012..8ebea0fc6 100644 --- a/apps/harbor/packages/server/src/policy.ts +++ b/apps/harbor/packages/server/src/policy.ts @@ -118,6 +118,18 @@ export function canCreateAgentKey(actor: Member, agent: Member): Decision { return { code: 'forbidden', message: 'only the agent’s owner can create its keys' }; } +/** Pointing an agent's alerts at a space (spec §8 Alerts, 2026-10-03): the owner, as with its keys. */ +export function canSetAgentHook(actor: Member, agent: Member): Decision { + if (agent.kind === 'agent' && agent.ownerId !== undefined && agent.ownerId === actor.id) return null; + return { code: 'forbidden', message: 'only the agent’s owner can set where its alerts go' }; +} + +/** Turning its alerts off: the owner, or any admin, like revoking a key. */ +export function canClearAgentHook(actor: Member, agent: Member): Decision { + if (agent.kind === 'agent' && (actor.role === 'admin' || (agent.ownerId !== undefined && agent.ownerId === actor.id))) return null; + return { code: 'forbidden', message: 'only the agent’s owner or an admin can turn its alerts off' }; +} + /** Revoking: the owner, or any admin — the off switch for an agent that misbehaves. */ export function canRevokeAgentKey(actor: Member, agent: Member): Decision { if (agent.kind === 'agent' && (actor.role === 'admin' || (agent.ownerId !== undefined && agent.ownerId === actor.id))) return null; diff --git a/apps/harbor/packages/server/src/runtime.ts b/apps/harbor/packages/server/src/runtime.ts index c4ffc99f0..36ef2a6cc 100644 --- a/apps/harbor/packages/server/src/runtime.ts +++ b/apps/harbor/packages/server/src/runtime.ts @@ -68,6 +68,7 @@ export async function buildOrgRuntime(input: OrgRuntimeInput): Promise connectors.verify(connection, secret), save: (agentId, secret, setBy) => connectors.save(agentId, secret, setBy), added: (agent) => connectors.ensure(agent), + alert: (connection, payload) => connectors.alert(connection, payload), }); await connectors.startAll(); const auth = bindAuth(input.auth, store); diff --git a/apps/harbor/packages/server/src/service.ts b/apps/harbor/packages/server/src/service.ts index b5e9c6c87..4cb9856d1 100644 --- a/apps/harbor/packages/server/src/service.ts +++ b/apps/harbor/packages/server/src/service.ts @@ -30,6 +30,7 @@ import type { Member, AgentKey, AgentCredential, + AgentHook, AgentKeySecret, AgentListing, ConnectorCapabilities, @@ -176,6 +177,19 @@ export class HarborService { setAgentCredential(ctx: ActorCtx, agentId: string, secret: string): Promise { return this.agents.setCredential(ctx, agentId, secret); } + setAgentHook(ctx: ActorCtx, agentId: string, spaceId: string): Promise<{ hook: AgentHook; token: string }> { + return this.agents.setHook(ctx, agentId, spaceId); + } + clearAgentHook(ctx: ActorCtx, agentId: string): Promise { + return this.agents.clearHook(ctx, agentId); + } + /** An alert at an agent's address (spec §8 Alerts, 2026-10-03), posted by the agent in its space; false when it has nothing to say. */ + async receiveAgentHook(agentId: string, token: string, payload: unknown): Promise { + const alert = await this.agents.hookAlert(agentId, token, payload); + if (!alert) return false; + await this.feed.postMessage({ memberId: agentId, agent: true }, alert.spaceId, { body: alert.body, actingMode: 'direct' }); + return true; + } createAgentKey(ctx: ActorCtx, agentId: string): Promise { return this.agents.createKey(ctx, agentId); } diff --git a/apps/harbor/packages/server/src/store.ts b/apps/harbor/packages/server/src/store.ts index c9f8a1dbf..f87fda2dc 100644 --- a/apps/harbor/packages/server/src/store.ts +++ b/apps/harbor/packages/server/src/store.ts @@ -1,6 +1,7 @@ import type { ActivityKind } from '@rowboat/spaces-protocol'; import type { AgentCredential, + AgentHook, AgentKey, Attribution, ConnectorCapabilities, @@ -131,6 +132,12 @@ export interface StoredAgentCredential extends AgentCredential { sealed: string; } +/** An alert hook as stored (spec §8 Alerts, 2026-10-03): what its owner sees, plus the hash of its secret. */ +export interface StoredAgentHook extends AgentHook { + agentId: string; + tokenHash: string; +} + export interface StoredEvent { offset: number; at: string; @@ -476,6 +483,11 @@ export interface Store { putAgentCredential(credential: StoredAgentCredential): Promise; /** Mark it rejected by its platform; true only the first time, until it is replaced. */ rejectAgentCredential(agentId: string, at: string, reason: string): Promise; + getAgentHook(agentId: string): Promise; + listAgentHooks(agentIds: string[]): Promise; + /** Set or replace an agent's hook; a replacement's secret is the only one that works. */ + putAgentHook(hook: StoredAgentHook): Promise; + deleteAgentHook(agentId: string): Promise; /** A connector's own record for one thread (its shape is the connector's). */ getConnectionThread(agentId: string, spaceId: string, threadRootId: string): Promise; putConnectionThread(agentId: string, spaceId: string, threadRootId: string, data: unknown, at: string): Promise; diff --git a/apps/harbor/packages/server/test/integrations.test.ts b/apps/harbor/packages/server/test/integrations.test.ts new file mode 100644 index 000000000..dcd0cf0de --- /dev/null +++ b/apps/harbor/packages/server/test/integrations.test.ts @@ -0,0 +1,325 @@ +import { createServer, type IncomingMessage, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { routes, type Invocation, type Member, type Message } from '@rowboat/spaces-protocol'; +import { MemoryBlobStore } from '../src/blobs.js'; +import { calAlert, calPlatform } from '../src/connectors/cal/index.js'; +import { commandText, zonedToUtc } from '../src/connectors/commands.js'; +import { PLATFORMS } from '../src/connectors/platforms.js'; +import { hogqlString, posthogAlert, posthogPlatform } from '../src/connectors/posthog/index.js'; +import { startHarbor, type RunningHarbor } from '../src/server.js'; +import { freshStore, restClient } from './helpers.js'; + +// Integrations and alerts (spec §8, 2026-10-03), end to end: a real Harbor +// running the PostHog and Cal.com connectors against stand-ins for their APIs +// that answer as their docs say, record every call, and refuse any key but +// the good one. + +interface Call { + method: string; + path: string; + headers: IncomingMessage['headers']; + body: unknown; +} + +/** A stand-in API: `route` answers [status, body] for a call; every call is kept. */ +async function fakeApi(route: (call: Call) => [number, unknown]): Promise<{ url: string; calls: Call[]; close: () => Promise }> { + const calls: Call[] = []; + const server: Server = createServer((req, res) => { + let raw = ''; + req.on('data', (chunk) => (raw += chunk)); + req.on('end', () => { + const call = { method: req.method ?? 'GET', path: req.url ?? '/', headers: req.headers, body: raw ? JSON.parse(raw) : undefined }; + calls.push(call); + const [status, body] = route(call); + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + return { url, calls, close: () => new Promise((resolve) => server.close(() => resolve())) }; +} + +const GOOD_POSTHOG = 'phx_good'; +const GOOD_CAL = 'cal_live_good'; + +function posthogRoute(call: Call): [number, unknown] { + if (call.headers.authorization !== `Bearer ${GOOD_POSTHOG}`) return [401, { detail: 'Invalid personal API key.' }]; + const path = call.path.split('?')[0]; + if (path === '/api/users/@me/') return [200, { team: { id: 42, name: 'Rowboat' } }]; + if (path === '/api/projects/42/query/') { + const query = (call.body as { query: { query: string } }).query.query; + if (query.includes('group by event')) return [200, { columns: ['event', 'total'], results: [['$pageview', 1200], ['signup', 31]] }]; + if (query.startsWith('select count()')) return [200, { columns: ['count()', 'count(distinct person_id)'], results: [[31, 29]] }]; + return [200, { columns: ['a'], results: [[1]] }]; + } + if (path === '/api/projects/42/insights/') return [200, { results: [{ short_id: 'AbC', name: 'Weekly signups' }] }]; + if (path === '/api/projects/42/feature_flags/') return [200, { results: [{ id: 7, key: 'new-onboarding', name: 'New onboarding', active: true }] }]; + return [404, { detail: 'Not found.' }]; +} + +function calRoute(call: Call): [number, unknown] { + if (calRevoked) return [401, { status: 'error', error: { message: 'API key revoked' } }]; + if (call.headers.authorization !== `Bearer ${GOOD_CAL}`) return [401, { status: 'error', error: { message: 'Invalid API Key' } }]; + const path = call.path.split('?')[0]; + if (path === '/me') return [200, { status: 'success', data: { id: 1, username: 'arjun', email: 'arjun@example.com', timeZone: 'America/New_York' } }]; + if (path === '/event-types') return [200, { status: 'success', data: [{ id: 10, slug: 'intro', title: 'Intro call', lengthInMinutes: 30 }] }]; + if (path === '/bookings' && call.method === 'GET') { + const afterStart = new URL(call.path, 'http://x').searchParams.get('afterStart')!; + const start = new Date(new Date(afterStart).getTime() + 15 * 3_600_000).toISOString(); + return [200, { status: 'success', data: [{ uid: 'bk_1', title: 'Intro call', start, status: 'accepted', location: 'https://meet.example.com/x', attendees: [{ name: 'Jane', email: 'jane@example.com' }] }] }]; + } + if (path === '/bookings' && call.method === 'POST') { + const body = call.body as { start: string }; + return [201, { status: 'success', data: { uid: 'bk_2', title: 'Intro call', start: body.start } }]; + } + if (path === '/bookings/bk_1/cancel') return [200, { status: 'success', data: { uid: 'bk_1', status: 'cancelled' } }]; + if (path === '/slots') return [200, { status: 'success', data: {} }]; + return [404, { status: 'error', error: { message: 'Not found' } }]; +} + +async function until(check: () => Promise, what: string, ms = 8_000): Promise { + const end = Date.now() + ms; + for (;;) { + const value = await check(); + if (value) return value as T; + if (Date.now() > end) throw new Error(`timed out waiting for ${what}`); + await new Promise((r) => setTimeout(r, 25)); + } +} + +const originals = { posthog: PLATFORMS.posthog, cal: PLATFORMS.cal }; +let posthog: Awaited>; +let cal: Awaited>; +let harbor: RunningHarbor; +let spaceId = ''; +const as = (token: string) => restClient(harbor, token); +/** A call with no bearer, as a service makes it. */ +const hook = (path: string, init: RequestInit) => fetch(`${harbor.url}${path}`, init); +const remove = async (token: string, path: string) => (await fetch(`${harbor.url}${path}`, { method: 'DELETE', headers: { authorization: `Bearer ${token}` } })).status; +/** Cal.com revoking the key: every call refused until it is put back. */ +let calRevoked = false; +const mention = (agent: Member) => `[@${agent.displayName}](#member:${agent.id})`; + +async function addAgent(name: string, connection: string, credential: string): Promise { + const created = await as('dev-ramnique').post('/v1/agents', { displayName: name, kind: connection, connection, credential }); + expect(created.status).toBe(200); + await as('dev-ramnique').post(`/v1/spaces/${spaceId}/members`, { memberIds: [created.body.agent.id], actingMode: 'direct' }); + return created.body.agent; +} + +/** Post a mention and wait for its invocation to end; the reply in its thread, if any. */ +async function ask(body: string): Promise<{ invocation: Invocation; reply?: Message }> { + const r = await as('dev-harsh').post(`/v1/spaces/${spaceId}/messages`, { body, actingMode: 'direct' }); + expect(r.status).toBe(200); + const posted = routes.postMessage.response.parse(r.body); + const id = posted.invocations[0]!.id; + const invocation = await until(async () => { + const all = (await as('dev-ramnique').get(`/v1/spaces/${spaceId}/invocations`)).body.invocations as Invocation[]; + const i = all.find((x) => x.id === id); + return i && ['done', 'failed', 'cancelled'].includes(i.state) ? i : undefined; + }, `invocation ${id}`); + const thread = (await as('dev-harsh').get(`/v1/spaces/${spaceId}/threads/${posted.message.id}`)).body.messages as Message[]; + return { invocation, reply: thread.find((m) => m.id !== posted.message.id) }; +} + +beforeAll(async () => { + process.env.HARBOR_INTEGRATION_KEY = 'c'.repeat(64); + posthog = await fakeApi(posthogRoute); + cal = await fakeApi(calRoute); + // A refusing region first, as US Cloud refuses an EU key. + const refusing = await fakeApi(() => [401, { detail: 'Invalid personal API key.' }]); + afterAll(() => refusing.close()); + PLATFORMS.posthog = posthogPlatform([refusing.url, posthog.url]); + PLATFORMS.cal = calPlatform(cal.url); + harbor = await startHarbor({ + store: (await freshStore()).store, + orgName: 'Rowboat Labs', + seedMembers: [ + { id: 'ramnique', displayName: 'Ramnique' }, + { id: 'harsh', displayName: 'Harsh' }, + { id: 'gagan', displayName: 'Gagan' }, + ], + blobs: new MemoryBlobStore(), + }); + await harbor.store.putMember({ id: 'gagan', displayName: 'Gagan', role: 'admin', kind: 'human' }); + spaceId = (await as('dev-ramnique').post('/v1/spaces', { name: 'Growth' })).body.space.id; + await as('dev-ramnique').post(`/v1/spaces/${spaceId}/members`, { memberIds: ['harsh'], actingMode: 'direct' }); +}); + +afterAll(async () => { + await harbor.close(); + await posthog.close(); + await cal.close(); + PLATFORMS.posthog = originals.posthog!; + PLATFORMS.cal = originals.cal!; + delete process.env.HARBOR_INTEGRATION_KEY; +}); + +describe('the PostHog agent', () => { + let agent: Member; + beforeAll(async () => { + agent = await addAgent('PostHog', 'posthog', GOOD_POSTHOG); + }); + + it('refuses a key PostHog does not accept, creating nothing', async () => { + const r = await as('dev-ramnique').post('/v1/agents', { displayName: 'Nope', kind: 'posthog', connection: 'posthog', credential: 'phx_bad' }); + expect(r).toMatchObject({ status: 400, body: { message: expect.stringMatching(/PostHog did not accept this key/) } }); + }); + + it('answers help, and an unknown command with the list', async () => { + const help = await ask(`${mention(agent)} help`); + expect(help.invocation.state).toBe('done'); + expect(help.reply?.body).toMatch(/@PostHog count \[days\]/); + const unknown = await ask(`${mention(agent)} dance`); + expect(unknown.reply?.body).toMatch(/I don't know "dance"/); + }); + + it('counts an event with HogQL, in the region that knows the key', async () => { + const { invocation, reply } = await ask(`${mention(agent)} count signup 30`); + expect(invocation.state).toBe('done'); + expect(reply?.body).toBe('**signup**, last 30 days: 31 times, 29 people.'); + const query = posthog.calls.filter((c) => c.path === '/api/projects/42/query/').at(-1)!; + expect((query.body as { query: { query: string } }).query.query).toContain(`event = 'signup' and timestamp > now() - interval 30 day`); + }); + + it('lists top events, insights and flags with links', async () => { + expect((await ask(`${mention(agent)} top`)).reply?.body).toContain('| $pageview | 1,200 |'); + expect((await ask(`${mention(agent)} insights`)).reply?.body).toBe(`- [Weekly signups](${posthog.url}/project/42/insights/AbC)`); + expect((await ask(`${mention(agent)} flags`)).reply?.body).toBe(`- 🟢 [\`new-onboarding\`](${posthog.url}/project/42/feature_flags/7): New onboarding`); + }); + + it('says how to use a command it was given wrongly', async () => { + const { invocation, reply } = await ask(`${mention(agent)} count signup 900`); + expect(invocation.state).toBe('done'); + expect(reply?.body).toMatch(/isn't a number of days[\s\S]*Usage: `@PostHog count \[days\]`/); + }); +}); + +describe('the Cal.com agent', () => { + let agent: Member; + beforeAll(async () => { + agent = await addAgent('Cal', 'cal', GOOD_CAL); + }); + + it("lists today's bookings in the owner's time zone, with their ids", async () => { + const { reply } = await ask(`${mention(agent)} today`); + expect(reply?.body).toMatch(/^Today:\n- \*\*.+ (EDT|EST)\*\*: Intro call with Jane · \[join\]\(https:\/\/meet\.example\.com\/x\) · `bk_1`$/); + const listed = cal.calls.find((c) => c.method === 'GET' && c.path.startsWith('/bookings'))!; + expect(listed.headers['cal-api-version']).toBe('2026-05-01'); + }); + + it('gives booking links', async () => { + expect((await ask(`${mention(agent)} links`)).reply?.body).toBe('- **Intro call** (`intro`, 30 min): https://cal.com/arjun/intro'); + }); + + it("books at a wall-clock time in the owner's zone", async () => { + const { reply } = await ask(`${mention(agent)} book intro 2026-10-05T15:00 jane@example.com Jane Doe`); + expect(reply?.body).toMatch(/^Booked: \*\*Intro call\*\*, Mon, Oct 5, 3:00 PM EDT with jane@example.com · `bk_2`$/); + const booked = cal.calls.find((c) => c.method === 'POST' && c.path === '/bookings')!; + expect(booked.body).toEqual({ eventTypeId: 10, start: '2026-10-05T19:00:00.000Z', attendee: { name: 'Jane Doe', email: 'jane@example.com', timeZone: 'America/New_York' } }); + }); + + it('cancels with a reason, and names an event type it does not have', async () => { + expect((await ask(`${mention(agent)} cancel bk_1 moved to next week`)).reply?.body).toBe('Cancelled `bk_1`.'); + expect(cal.calls.find((c) => c.path === '/bookings/bk_1/cancel')!.body).toEqual({ cancellationReason: 'moved to next week' }); + expect((await ask(`${mention(agent)} slots demo`)).reply?.body).toMatch(/There's no event type "demo". Yours: intro\./); + }); + + it('fails fast and marks the key rejected when Cal.com stops accepting it', async () => { + calRevoked = true; + const { invocation, reply } = await ask(`${mention(agent)} today`); + calRevoked = false; + expect(invocation).toMatchObject({ state: 'failed', error: expect.stringMatching(/Cal.com did not accept my key \(API key revoked\)\. Its owner needs to replace the key in Agents\./) }); + expect(reply).toBeUndefined(); + const listed = (await as('dev-ramnique').get('/v1/agents')).body.agents.find((a: { agent: Member }) => a.agent.id === agent.id); + expect(listed.credential.rejectedReason).toMatch(/API key revoked/); + expect((await as('dev-ramnique').put(`/v1/agents/${agent.id}/credential`, { secret: GOOD_CAL })).status).toBe(200); + expect((await ask(`${mention(agent)} links`)).invocation.state).toBe('done'); + }); +}); + +describe('alert hooks', () => { + let agent: Member; + let url = ''; + beforeAll(async () => { + agent = (await as('dev-ramnique').get('/v1/agents')).body.agents.find((a: { agent: Member }) => a.agent.agentConnection === 'cal').agent; + }); + + it('are set by the owner, in a space with the agent, and shown once', async () => { + expect((await as('dev-harsh').put(`/v1/agents/${agent.id}/hook`, { spaceId })).status).toBe(403); + const other = (await as('dev-ramnique').post('/v1/spaces', { name: 'Elsewhere' })).body.space.id; + expect(await as('dev-ramnique').put(`/v1/agents/${agent.id}/hook`, { spaceId: other })).toMatchObject({ status: 400, body: { message: 'add Cal to the space first' } }); + const set = await as('dev-ramnique').put(`/v1/agents/${agent.id}/hook`, { spaceId }); + expect(set.status).toBe(200); + url = set.body.url; + expect(url).toMatch(new RegExp(`/v1/hooks/${agent.id}/rbh_[A-Za-z0-9_-]{43}$`)); + const listed = (await as('dev-ramnique').get('/v1/agents')).body.agents.find((a: { agent: Member }) => a.agent.id === agent.id); + expect(listed.hook).toEqual({ spaceId, setBy: 'ramnique', setAt: expect.any(String) }); + expect(JSON.stringify(listed)).not.toContain('rbh_'); + }); + + it("post the service's alert into the space as the agent, without a bearer", async () => { + const path = new URL(url).pathname; + const r = await hook(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ triggerEvent: 'BOOKING_CREATED', payload: { title: 'Intro call', startTime: '2026-10-05T19:00:00Z', organizer: { timeZone: 'America/New_York' }, attendees: [{ name: 'Jane' }] } }), + }); + expect(r.status).toBe(200); + expect(await r.json()).toEqual({ posted: true }); + const stream = (await as('dev-harsh').get(`/v1/spaces/${spaceId}/stream?limit=5`)).body.messages as Message[]; + const alert = stream.find((m) => m.author.memberId === agent.id && !m.threadRoot); + expect(alert?.body).toBe('📅 New booking: **Intro call**, Mon, Oct 5, 3:00 PM EDT with Jane'); + }); + + it('refuse a wrong secret like an unknown agent, and say nothing for an unknown event', async () => { + const send = (path: string, body: unknown) => hook(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + expect((await send(`/v1/hooks/${agent.id}/rbh_wrong`, { triggerEvent: 'PING' })).status).toBe(404); + expect((await send(`/v1/hooks/nobody/rbh_wrong`, { triggerEvent: 'PING' })).status).toBe(404); + const path = new URL(url).pathname; + expect(await (await send(path, { hello: 'world' })).json()).toEqual({ posted: false }); + expect((await hook(path, { method: 'POST', body: 'not json' })).status).toBe(400); + }); + + it('stop working once replaced or turned off', async () => { + const path = new URL(url).pathname; + const again = await as('dev-ramnique').put(`/v1/agents/${agent.id}/hook`, { spaceId }); + const send = (p: string) => hook(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ triggerEvent: 'PING' }) }); + expect((await send(path)).status).toBe(404); + const fresh = new URL(again.body.url).pathname; + expect((await send(fresh)).status).toBe(200); + expect(await remove('dev-harsh', `/v1/agents/${agent.id}/hook`)).toBe(403); + expect(await remove('dev-gagan', `/v1/agents/${agent.id}/hook`)).toBe(200); + expect((await send(fresh)).status).toBe(404); + }); +}); + +describe('integration pieces', () => { + it('reads the command without the agent’s own mention', () => { + expect(commandText('[@Cal](#member:a1) book intro with [@Jane](#member:j)', 'a1')).toBe('book intro with @Jane'); + }); + + it('turns a wall-clock time in a zone into the instant, across DST', () => { + expect(zonedToUtc('2026-10-05T15:00:00', 'America/New_York').toISOString()).toBe('2026-10-05T19:00:00.000Z'); + expect(zonedToUtc('2026-12-05T15:00:00', 'America/New_York').toISOString()).toBe('2026-12-05T20:00:00.000Z'); + expect(zonedToUtc('2026-10-05T09:30:00', 'Asia/Kolkata').toISOString()).toBe('2026-10-05T04:00:00.000Z'); + }); + + it('quotes HogQL strings', () => { + expect(hogqlString("it's \\ here")).toBe("'it\\'s \\\\ here'"); + }); + + it('says Cal.com alerts by trigger, and PostHog alerts by what the template sent', () => { + expect(calAlert({ triggerEvent: 'PING', payload: {} })).toMatch(/connected/); + expect(calAlert({ triggerEvent: 'BOOKING_CANCELLED', payload: { title: 'Intro', cancellationReason: 'sick' } })).toBe('❌ Cancelled: **Intro**\n> sick'); + expect(calAlert({ triggerEvent: 'MEETING_ENDED', title: 'Intro' })).toBe('⏹️ Meeting ended: **Intro**'); + expect(calAlert({ hello: 1 })).toBeUndefined(); + expect(posthogAlert({ text: 'Signups dropped 40%' })).toBe('Signups dropped 40%'); + expect(posthogAlert({ event: { event: '$insight_alert_firing', properties: { alert_name: 'Signups low', insight_name: 'Weekly signups', insight_url: 'https://us.posthog.com/x' } } })).toBe( + '🔔 **Signups low** on Weekly signups ($insight_alert_firing)\nhttps://us.posthog.com/x', + ); + expect(posthogAlert({ other: true })).toMatch(/^🔔 PostHog sent:/); + }); +}); diff --git a/apps/harbor/packages/server/test/policy.test.ts b/apps/harbor/packages/server/test/policy.test.ts index 04b71f38c..d2c55da2c 100644 --- a/apps/harbor/packages/server/test/policy.test.ts +++ b/apps/harbor/packages/server/test/policy.test.ts @@ -11,6 +11,8 @@ import { canAddAgent, canCreateAgentKey, canRevokeAgentKey, + canSetAgentHook, + canClearAgentHook, canReadSpace, canJoinSpace, canBind, @@ -115,6 +117,10 @@ describe('policy', () => { expect(canRevokeAgentKey(owner, hermes)).toBeNull(); expect(canRevokeAgentKey(admin, hermes)).toBeNull(); expect(canRevokeAgentKey(other, hermes)).toMatchObject({ code: 'forbidden' }); + expect(canSetAgentHook(owner, hermes)).toBeNull(); + expect(canSetAgentHook(admin, hermes)).toMatchObject({ code: 'forbidden' }); + expect(canClearAgentHook(admin, hermes)).toBeNull(); + expect(canClearAgentHook(other, hermes)).toMatchObject({ code: 'forbidden' }); expect(agentsManagedBy(admin)).toBe('all'); expect(agentsManagedBy(owner)).toBe('owned'); }); diff --git a/apps/x/apps/main/src/spaces/ipc.ts b/apps/x/apps/main/src/spaces/ipc.ts index a4064394e..cde791382 100644 --- a/apps/x/apps/main/src/spaces/ipc.ts +++ b/apps/x/apps/main/src/spaces/ipc.ts @@ -41,6 +41,8 @@ type SpacesHandlers = { 'spaces:listAgents': InvokeHandler<'spaces:listAgents'>; 'spaces:addAgent': InvokeHandler<'spaces:addAgent'>; 'spaces:setAgentCredential': InvokeHandler<'spaces:setAgentCredential'>; + 'spaces:setAgentHook': InvokeHandler<'spaces:setAgentHook'>; + 'spaces:clearAgentHook': InvokeHandler<'spaces:clearAgentHook'>; 'spaces:createAgentKey': InvokeHandler<'spaces:createAgentKey'>; 'spaces:revokeAgentKey': InvokeHandler<'spaces:revokeAgentKey'>; 'spaces:openDirect': InvokeHandler<'spaces:openDirect'>; @@ -240,6 +242,11 @@ export const spacesIpcHandlers: SpacesHandlers = { 'spaces:listAgents': async (_event, args) => ({ agents: await orgs.getClient(args.orgId).listAgents() }), 'spaces:addAgent': async (_event, { orgId, ...input }) => orgs.getClient(orgId).addAgent(input), 'spaces:setAgentCredential': async (_event, args) => ({ credential: await orgs.getClient(args.orgId).setAgentCredential(args.agentId, args.secret) }), + 'spaces:setAgentHook': async (_event, args) => orgs.getClient(args.orgId).setAgentHook(args.agentId, args.spaceId), + 'spaces:clearAgentHook': async (_event, args) => { + await orgs.getClient(args.orgId).clearAgentHook(args.agentId); + return {}; + }, 'spaces:createAgentKey': async (_event, args) => ({ key: await orgs.getClient(args.orgId).createAgentKey(args.agentId) }), 'spaces:revokeAgentKey': async (_event, args) => ({ key: await orgs.getClient(args.orgId).revokeAgentKey(args.agentId, args.keyId) }), diff --git a/apps/x/apps/renderer/src/components/spaces/agent-page.tsx b/apps/x/apps/renderer/src/components/spaces/agent-page.tsx index d11b641da..d6f61ba4d 100644 --- a/apps/x/apps/renderer/src/components/spaces/agent-page.tsx +++ b/apps/x/apps/renderer/src/components/spaces/agent-page.tsx @@ -3,7 +3,7 @@ import { KeyRound, Loader2 } from 'lucide-react' import type { spaces } from '@x/shared' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { ConnectAgent } from '@/components/spaces/agent-setup' +import { ConnectAgent, CopyField } from '@/components/spaces/agent-setup' import { refreshAgentCapabilities } from '@/hooks/use-space-invocations' import type { OrgWithSpaces } from '@/hooks/use-spaces' import { PLATFORMS, setupFor } from '@/lib/agent-kinds' @@ -44,7 +44,8 @@ export function AgentPage({ org, listing, isAdmin, onChanged }: { /** Reload the listing after a change to keys or the platform key. */ onChanged: () => Promise }) { - const { agent, keys, credential } = listing + const { agent, keys, credential, hook } = listing + const setup = setupFor(agent) const mine = agent.ownerId === org.memberId const live = keys.filter((k) => !k.revokedAt) // A key made here, shown this once and filled into the setup steps. @@ -93,9 +94,13 @@ export function AgentPage({ org, listing, isAdmin, onChanged }: { : `A key is shown only once, when it is made. Where the steps say ${KEY_PLACEHOLDER}, use that key${mine ? ', or make a new one to fill them in' : ''}.` } > - + + {setup.alerts && ( + + )} +
{live.length === 0 ? (

No active keys.

@@ -210,7 +215,93 @@ function DefaultsSection({ orgId, agent, canEdit }: { orgId: string; agent: spac ) } -/** A platform agent's key (Replicas): its last characters, whether the platform rejected it, and Replace for its owner. */ +/** + * Where the service's alerts land (Harbor spec §8 Alerts, 2026-10-03): its + * owner picks a space the agent is in and gets a secret address, shown this + * once, to paste into the service's webhook settings. Getting a new one stops + * the old; the owner or an admin turns alerts off. + */ +function AlertsSection({ org, agent, hook, note, canSet, canClear, onChanged }: { + org: OrgWithSpaces + agent: spaces.Member + hook: spaces.AgentHook | undefined + note: string + canSet: boolean + canClear: boolean + onChanged: () => Promise +}) { + const [spaceId, setSpaceId] = useState(hook?.spaceId ?? '') + const [url, setUrl] = useState(null) + const [busy, setBusy] = useState(false) + const spaceName = (id: string) => org.spaces.find((s) => s.id === id)?.name ?? 'a space' + + const set = async () => { + if (!spaceId || busy) return + setBusy(true) + try { + const result = await window.ipc.invoke('spaces:setAgentHook', { orgId: org.id, agentId: agent.id, spaceId }) + setUrl(result.url) + await onChanged() + } catch (err) { + toast(err instanceof Error ? err.message : 'Could not set up alerts', 'error') + } finally { + setBusy(false) + } + } + + const clear = async () => { + if (busy) return + setBusy(true) + try { + await window.ipc.invoke('spaces:clearAgentHook', { orgId: org.id, agentId: agent.id }) + setUrl(null) + toast('Alerts turned off', 'success') + await onChanged() + } catch (err) { + toast(err instanceof Error ? err.message : 'Could not turn alerts off', 'error') + } finally { + setBusy(false) + } + } + + return ( +
+ {hook && !url && ( +

+ Posting to #{spaceName(hook.spaceId)} since {when(hook.setAt)}. A new address stops the old one. +

+ )} + {url && } + {canSet && ( +
+ + +
+ )} + {hook && canClear && ( + + )} +
+ ) +} + +/** A platform agent's key (Replicas, PostHog, Cal.com): its last characters, whether the platform rejected it, and Replace for its owner. */ export function CredentialRow({ orgId, agent, credential, canReplace, onReplaced }: { orgId: string agent: spaces.Member diff --git a/apps/x/apps/renderer/src/components/spaces/agent-setup.tsx b/apps/x/apps/renderer/src/components/spaces/agent-setup.tsx index 6c4f469bb..7a3aea35f 100644 --- a/apps/x/apps/renderer/src/components/spaces/agent-setup.tsx +++ b/apps/x/apps/renderer/src/components/spaces/agent-setup.tsx @@ -87,7 +87,7 @@ function CodeBlock({ text, caption, label, secret }: { text: string; caption: st } /** One value to paste (a key, an address), with Copy beside it and what it is before it. */ -function CopyField({ value }: { value: SetupValue }) { +export function CopyField({ value }: { value: SetupValue }) { return (
{value.label && {value.label}} diff --git a/apps/x/apps/renderer/src/components/spaces/agents-dialog.test.tsx b/apps/x/apps/renderer/src/components/spaces/agents-dialog.test.tsx index 1af13e585..9feb1132d 100644 --- a/apps/x/apps/renderer/src/components/spaces/agents-dialog.test.tsx +++ b/apps/x/apps/renderer/src/components/spaces/agents-dialog.test.tsx @@ -13,12 +13,14 @@ const person = (id: string, displayName: string, role: 'admin' | 'member' = 'mem const hermes = { id: 'hermes', displayName: 'Hermes', role: 'member', kind: 'agent', ownerId: 'me', agentKind: 'hermes', agentConnection: 'plugin' } as spaces.Member const scout = { id: 'scout', displayName: 'Scout', role: 'member', kind: 'agent', ownerId: 'harsh', agentKind: 'custom', agentConnection: 'contract' } as spaces.Member const claude = { id: 'claude', displayName: 'Claude', role: 'member', kind: 'agent', ownerId: 'me', agentKind: 'claude-code', agentConnection: 'replicas' } as spaces.Member +const cal = { id: 'cal', displayName: 'Cal', role: 'member', kind: 'agent', ownerId: 'me', agentKind: 'cal', agentConnection: 'cal' } as spaces.Member const key = (id: string, agentId: string, extra: Partial = {}): spaces.AgentKey => ({ id, agentId, createdBy: 'me', createdAt: '2026-09-29T10:00:00Z', ...extra }) const org = { id: 'org-1', name: 'Rowboat Labs', baseUrl: 'https://rowboat.example', spaces: [], directs: [], memberId: 'me' } as unknown as OrgWithSpaces // Real keys are long: on screen they read as their ends, and Copy copies them whole. const NEW_KEY = 'rbk_LXNtIBXAl77aMYBiJ2Z2qesaySzSrqaJCbspX72zz9U' +const HOOK_URL = 'https://rowboat.example/v1/hooks/cal/rbh_H00kH00kH00kH00kH00kH00kH00kH00kH00kH00kH00' const ROTATED = 'rbk_R0tat3dR0tat3dR0tat3dR0tat3dR0tat3dR0tat3d' let roster: spaces.Member[] @@ -39,6 +41,8 @@ const invoke = vi.fn(async (channel: string, args: Record) => { case 'spaces:createAgentKey': return { key: { ...key('k2', args.agentId!), secret: ROTATED } } case 'spaces:revokeAgentKey': return { key: key(args.keyId!, args.agentId!, { revokedAt: '2026-09-29T11:00:00Z' }) } case 'spaces:getAgentCapabilities': return { capabilities: { stop: false, options: options[args.agentId!] ?? [] }, defaults: defaults[args.agentId!] ?? {} } + case 'spaces:setAgentHook': return { hook: { spaceId: args.spaceId, setBy: 'me', setAt: '2026-10-03T10:00:00Z' }, url: HOOK_URL } + case 'spaces:clearAgentHook': return {} case 'spaces:setAgentOptionDefaults': return { defaults: (args as unknown as { defaults: Record }).defaults } } throw new Error(`unexpected ${channel}`) @@ -73,7 +77,7 @@ describe('AgentsDialog', () => { render() await screen.findByText('Hermes') fireEvent.click(screen.getByRole('button', { name: /Add agent/ })) - expect(setupsOffered()).toEqual(['Hermes', 'Replicas', 'Custom']) + expect(setupsOffered()).toEqual(['Hermes', 'Replicas', 'PostHog', 'Cal.com', 'Custom']) expect(screen.getByRole('radio', { name: 'Hermes' })).toHaveAttribute('aria-checked', 'true') // The name suggests the kind's own. expect(screen.getByLabelText('Agent name')).toHaveValue('Hermes') @@ -252,6 +256,37 @@ describe('AgentsDialog', () => { await waitFor(() => expect(invoke).toHaveBeenCalledWith('spaces:setAgentCredential', { orgId: 'org-1', agentId: 'claude', secret: 'rpl_live_cd34' })) }) + it('adds a Cal.com agent on its key, and says how to use it', async () => { + render() + await screen.findByText('Hermes') + fireEvent.click(screen.getByRole('button', { name: /Add agent/ })) + fireEvent.click(screen.getByRole('radio', { name: 'Cal.com' })) + expect(screen.getByLabelText('Agent name')).toHaveValue('Cal') + fireEvent.change(screen.getByLabelText('Cal.com API key'), { target: { value: 'cal_live_ab12' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add agent' })) + await screen.findByRole('heading', { name: 'Connect Cal' }) + expect(invoke).toHaveBeenCalledWith('spaces:addAgent', { orgId: 'org-1', displayName: 'Cal', kind: 'cal', connection: 'cal', credential: 'cal_live_ab12' }) + expect(screen.getByText(/such as @Cal today/)).toBeInTheDocument() + }) + + it('lets the owner send an integration’s alerts to a space, shows the address once, and turns them off', async () => { + const withSpaces = { ...org, spaces: [{ id: 'S1', name: 'growth' }] } as unknown as OrgWithSpaces + listing = [{ agent: cal, keys: [key('k5', 'cal')], credential: { hint: '…ab12', setBy: 'me', setAt: '2026-10-03T09:00:00Z' } }] + render() + await screen.findByText('Cal') + expect(screen.getByText(/^Cal\.com · Yours · 1 key/)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Cal/ })) + fireEvent.change(await screen.findByLabelText('Space for alerts'), { target: { value: 'S1' } }) + listing = [{ ...listing[0]!, hook: { spaceId: 'S1', setBy: 'me', setAt: '2026-10-03T10:00:00Z' } }] + fireEvent.click(screen.getByRole('button', { name: 'Get address' })) + await waitFor(() => expect(invoke).toHaveBeenCalledWith('spaces:setAgentHook', { orgId: 'org-1', agentId: 'cal', spaceId: 'S1' })) + fireEvent.click(await screen.findByRole('button', { name: `Copy URL` })) + expect(vi.mocked(navigator.clipboard.writeText)).toHaveBeenCalledWith(HOOK_URL) + expect(screen.getByText(/only time this address is shown/)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Turn alerts off' })) + await waitFor(() => expect(invoke).toHaveBeenCalledWith('spaces:clearAgentHook', { orgId: 'org-1', agentId: 'cal' })) + }) + it('revokes on a second click', async () => { render() fireEvent.click(await screen.findByRole('button', { name: /Hermes/ })) diff --git a/apps/x/apps/renderer/src/lib/agent-kinds.ts b/apps/x/apps/renderer/src/lib/agent-kinds.ts index e61a45964..541e89012 100644 --- a/apps/x/apps/renderer/src/lib/agent-kinds.ts +++ b/apps/x/apps/renderer/src/lib/agent-kinds.ts @@ -43,12 +43,17 @@ export const KINDS: Record = { pi: { label: 'Pi', logo: { light: piLogo, dark: piLogoDark } }, // Meta publishes no mark for Muse Code (2026-09-30): the generic one until it does. 'muse-code': { label: 'Muse Code' }, + // Integrations (2026-10-03) show the generic mark until their official ones are added. + posthog: { label: 'PostHog' }, + cal: { label: 'Cal.com' }, custom: { label: 'Agent' }, } /** Platforms Harbor calls on an agent's behalf: named after the kind ("via Replicas"). */ export const PLATFORMS: Record = { replicas: { label: 'Replicas', logo: { light: replicasLogo, dark: replicasLogoDark } }, + posthog: { label: 'PostHog' }, + cal: { label: 'Cal.com' }, } export function kindInfo(kind: string | undefined): { label: string; logo?: Logo } { @@ -59,7 +64,8 @@ export function kindInfo(kind: string | undefined): { label: string; logo?: Logo export function agentLabel(kind: string | undefined, connection: string | undefined): string { const platform = connection ? PLATFORMS[connection] : undefined const what = kindInfo(kind).label - return platform ? `${what} · via ${platform.label}` : what + // An integration is its own platform: "PostHog", not "PostHog · via PostHog". + return platform && platform.label !== what ? `${what} · via ${platform.label}` : what } /** Something to paste, with Copy. `secret` values read as their ends on screen and copy whole. */ @@ -114,6 +120,8 @@ export interface AgentSetup { docsUrl?: string /** Opens the owner's DM with the agent before setup, for `homeChannel`. */ wantsHomeChannel?: boolean + /** The service can post alerts through the agent (Harbor spec §8 Alerts): where its address goes, on the agent's page. */ + alerts?: { note: string } setup: (ctx: SetupContext) => SetupRoute[] } @@ -296,6 +304,27 @@ function replicasSetup({ orgUrl, agentKey }: SetupContext): SetupRoute[] { ] } +// An integration (Harbor spec §8 Integrations, 2026-10-03) needs nothing on the service's side +// beyond its key: it answers commands, and alerts are set up on its page. +function integrationSetup(name: string, example: string): () => SetupRoute[] { + return () => [ + { + id: 'commands', + label: 'Commands', + steps: [ + { + title: 'Ask it what it can do', + note: `Mention it with help for its commands, such as @${name} ${example}. It calls the service's API with your key and replies in the thread.`, + }, + { + title: 'Send its alerts to a space (optional)', + note: "On this agent's page, under Alerts, pick a space it is in and copy the address it gives you into the service's webhook settings.", + }, + ], + }, + ] +} + export const AGENT_SETUPS: readonly AgentSetup[] = [ { id: 'hermes', @@ -325,6 +354,42 @@ export const AGENT_SETUPS: readonly AgentSetup[] = [ docsUrl: 'https://docs.replicas.dev/features/environments', setup: replicasSetup, }, + { + id: 'posthog', + label: 'PostHog', + description: 'Your PostHog project: event counts, HogQL, insights, flags and alerts', + connection: 'posthog', + kinds: ['posthog'], + defaultName: 'PostHog', + credential: { + label: 'PostHog personal API key', + placeholder: 'phx_… (self-hosted: https://your-posthog phx_…)', + note: 'Settings → Personal API keys, with read access to user, query, insight and feature flag. Harbor checks it with PostHog and keeps it sealed.', + }, + docsUrl: 'https://posthog.com/docs/api', + alerts: { + note: 'In PostHog, Data pipelines → Destinations → New → HTTP Webhook, with this as the URL. Send it from an alert, or any event. A body of {"text": "…"} is posted as written.', + }, + setup: integrationSetup('PostHog', 'count signup 30'), + }, + { + id: 'cal', + label: 'Cal.com', + description: 'Your Cal.com calendar: bookings, links, open times, booking and cancelling', + connection: 'cal', + kinds: ['cal'], + defaultName: 'Cal', + credential: { + label: 'Cal.com API key', + placeholder: 'cal_live_…', + note: 'Settings → Developer → API keys. The agent acts as you, in your time zone. Harbor checks it with Cal.com and keeps it sealed.', + }, + docsUrl: 'https://cal.com/docs/api-reference/v2/introduction', + alerts: { + note: 'In Cal.com, Settings → Developer → Webhooks → New, with this as the Subscriber URL and the booking events you want. Ping test posts a hello.', + }, + setup: integrationSetup('Cal', 'today'), + }, { id: 'custom', label: 'Custom', diff --git a/apps/x/packages/core/src/spaces/client.ts b/apps/x/packages/core/src/spaces/client.ts index 1e95fa33a..8b838215f 100644 --- a/apps/x/packages/core/src/spaces/client.ts +++ b/apps/x/packages/core/src/spaces/client.ts @@ -12,6 +12,7 @@ import { type CreateInviteResult, type AgentKey, type AgentCredential, + type AgentHook, type AgentKeySecret, type AgentListing, type ConnectorCapabilities, @@ -159,7 +160,7 @@ export class SpacesClient { } private async request( - method: 'GET' | 'POST' | 'PUT', + method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, responseSchema: S, body?: unknown, @@ -267,6 +268,15 @@ export class SpacesClient { return (await this.request('PUT', `/v1/agents/${encodeURIComponent(agentId)}/credential`, routes.setAgentCredential.response, { secret })).credential; } + /** Point a platform agent's alerts at a space; the address is returned this once (api.ts setAgentHook). */ + async setAgentHook(agentId: string, spaceId: string): Promise<{ hook: AgentHook; url: string }> { + return this.request('PUT', `/v1/agents/${encodeURIComponent(agentId)}/hook`, routes.setAgentHook.response, { spaceId }); + } + + async clearAgentHook(agentId: string): Promise { + await this.request('DELETE', `/v1/agents/${encodeURIComponent(agentId)}/hook`, routes.clearAgentHook.response); + } + async createAgentKey(agentId: string): Promise { return (await this.request('POST', `/v1/agents/${encodeURIComponent(agentId)}/keys`, routes.createAgentKey.response)).key; } diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index c6818e8b9..988a9c485 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -3928,6 +3928,15 @@ export const ipcSchemas = { req: z.object({ orgId: z.string(), agentId: z.string(), secret: z.string() }), res: z.object({ credential: z.custom() }), }, + // A platform agent's alerts (2026-10-03): the address crosses IPC once, on its way to the screen. + 'spaces:setAgentHook': { + req: z.object({ orgId: z.string(), agentId: z.string(), spaceId: z.string() }), + res: z.object({ hook: z.custom(), url: z.string() }), + }, + 'spaces:clearAgentHook': { + req: z.object({ orgId: z.string(), agentId: z.string() }), + res: z.object({}), + }, 'spaces:createAgentKey': { req: z.object({ orgId: z.string(), agentId: z.string() }), res: z.object({ key: z.custom() }), diff --git a/apps/x/packages/shared/src/spaces.ts b/apps/x/packages/shared/src/spaces.ts index 9651bf7fb..e3362ff22 100644 --- a/apps/x/packages/shared/src/spaces.ts +++ b/apps/x/packages/shared/src/spaces.ts @@ -3,6 +3,7 @@ import { addressesRowboat, mapMentionTokens, mentionsAsText } from '@rowboat/spa import type { AcceptInviteResult, AgentCredential, + AgentHook, AgentKey, AgentKeySecret, AgentListing, @@ -64,6 +65,7 @@ import type { export type { AcceptInviteResult, AgentCredential, + AgentHook, AgentKey, AgentKeySecret, AgentListing, From 1fbd71ac0cbabc96cb2ad8b89c3e8ae671980d78 Mon Sep 17 00:00:00 2001 From: "replicas-connector[bot]" Date: Sat, 3 Oct 2026 09:33:43 +0000 Subject: [PATCH 2/2] Call PostHog and Cal.com bots in the app They run a service's commands, as Slack bots do, rather than work things out, so the Agents dialog lists them apart, adds them under Add bot, and labels them "PostHog bot" and "Cal.com bot" wherever an agent's kind shows. Harbor keeps them agent members. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: arkml <6592213+arkml@users.noreply.github.com> Co-Authored-By: replicas-connector[bot] --- apps/harbor/SPEC.md | 2 +- .../components/spaces/agents-dialog.test.tsx | 25 ++-- .../src/components/spaces/agents-dialog.tsx | 115 +++++++++++------- .../src/components/spaces/server-switcher.tsx | 2 +- apps/x/apps/renderer/src/lib/agent-kinds.ts | 33 +++-- 5 files changed, 111 insertions(+), 66 deletions(-) diff --git a/apps/harbor/SPEC.md b/apps/harbor/SPEC.md index edd3e1c7e..12ed917cb 100644 --- a/apps/harbor/SPEC.md +++ b/apps/harbor/SPEC.md @@ -556,7 +556,7 @@ Every agent member, whatever runs it (a Hermes or OpenClaw gateway, Claude Code, - **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. - **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. -- **Integrations: a service's own agent, without a model** *(2026-10-03)*. A service people already use from Slack (PostHog, Cal.com) gets an agent of its own kind and connection (`posthog`, `cal`), added with the org's key for the service like any platform agent (above). As in the services' Slack apps, nothing interprets the request: a mention is a command (`@Cal today`, `@PostHog count signup 30`), the connector calls the service's API with the stored key, and the answer is the reply; `help`, or an unknown command, answers with the list. A command is one call and cannot be stopped midway, and one that a restart interrupts fails with "check, then mention me again" rather than run twice, since a booking made twice is worse than one asked for again. Everything but the client, the commands and the alert wording is shared (`src/connectors/commands.ts`), so another service is one module. Conversational requests, link previews of the service's private pages, and buttons on its replies are not built: the first needs a model on Harbor's side, the other two a counterpart in Spaces (above). +- **Integrations: a service's own agent, without a model** *(2026-10-03)*. A service people already use from Slack (PostHog, Cal.com) gets an agent of its own kind and connection (`posthog`, `cal`), added with the org's key for the service like any platform agent (above). As in the services' Slack apps, nothing interprets the request: a mention is a command (`@Cal today`, `@PostHog count signup 30`), the connector calls the service's API with the stored key, and the answer is the reply; `help`, or an unknown command, answers with the list. A command is one call and cannot be stopped midway, and one that a restart interrupts fails with "check, then mention me again" rather than run twice, since a booking made twice is worse than one asked for again. Everything but the client, the commands and the alert wording is shared (`src/connectors/commands.ts`), so another service is one module. The app calls these **bots**, as Slack does, and lists and adds them apart from agents (Arjun, 2026-10-03): they run commands rather than work things out. On the wire they stay agent members, since every rule for agents (mentions, the queue, keys, hops) already fits them, and one that later takes plain requests becomes an agent again with no change to its data. Conversational requests, link previews of the service's private pages, and buttons on its replies are not built: the first needs a model on Harbor's side, the other two a counterpart in Spaces (above). - **Alerts: a service posts through its agent** *(2026-10-03)*. The owner of a platform agent points its alerts at a space both of them belong to, and gets a secret address once, to paste into the service's webhook settings; setting it again replaces the secret, and the owner or an admin turns it off. The service posts JSON there without a bearer (the secret is the credential, kept as a hash like an agent key); the agent's connector says what it means, and the agent posts that in the space as itself, so it reads, notifies and is searched like any message. An event the connector has no words for is not posted. One space per agent; a second space is a second agent. - **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/x/apps/renderer/src/components/spaces/agents-dialog.test.tsx b/apps/x/apps/renderer/src/components/spaces/agents-dialog.test.tsx index 9feb1132d..6e5936597 100644 --- a/apps/x/apps/renderer/src/components/spaces/agents-dialog.test.tsx +++ b/apps/x/apps/renderer/src/components/spaces/agents-dialog.test.tsx @@ -77,7 +77,7 @@ describe('AgentsDialog', () => { render() await screen.findByText('Hermes') fireEvent.click(screen.getByRole('button', { name: /Add agent/ })) - expect(setupsOffered()).toEqual(['Hermes', 'Replicas', 'PostHog', 'Cal.com', 'Custom']) + expect(setupsOffered()).toEqual(['Hermes', 'Replicas', 'Custom']) expect(screen.getByRole('radio', { name: 'Hermes' })).toHaveAttribute('aria-checked', 'true') // The name suggests the kind's own. expect(screen.getByLabelText('Agent name')).toHaveValue('Hermes') @@ -256,28 +256,35 @@ describe('AgentsDialog', () => { await waitFor(() => expect(invoke).toHaveBeenCalledWith('spaces:setAgentCredential', { orgId: 'org-1', agentId: 'claude', secret: 'rpl_live_cd34' })) }) - it('adds a Cal.com agent on its key, and says how to use it', async () => { + it('adds a Cal.com bot on its key, under Add bot, and says how to use it', async () => { render() await screen.findByText('Hermes') - fireEvent.click(screen.getByRole('button', { name: /Add agent/ })) + fireEvent.click(screen.getByRole('button', { name: /Add bot/ })) + expect(screen.getByRole('heading', { name: 'Add a bot' })).toBeInTheDocument() + expect(within(screen.getByRole('radiogroup', { name: 'Which service' })).getAllByRole('radio').map((r) => r.getAttribute('aria-label'))).toEqual(['PostHog', 'Cal.com']) fireEvent.click(screen.getByRole('radio', { name: 'Cal.com' })) - expect(screen.getByLabelText('Agent name')).toHaveValue('Cal') + expect(screen.getByLabelText('Bot name')).toHaveValue('Cal') fireEvent.change(screen.getByLabelText('Cal.com API key'), { target: { value: 'cal_live_ab12' } }) - fireEvent.click(screen.getByRole('button', { name: 'Add agent' })) + fireEvent.click(screen.getByRole('button', { name: 'Add bot' })) await screen.findByRole('heading', { name: 'Connect Cal' }) expect(invoke).toHaveBeenCalledWith('spaces:addAgent', { orgId: 'org-1', displayName: 'Cal', kind: 'cal', connection: 'cal', credential: 'cal_live_ab12' }) expect(screen.getByText(/such as @Cal today/)).toBeInTheDocument() }) - it('lets the owner send an integration’s alerts to a space, shows the address once, and turns them off', async () => { + it('lets the owner send a bot’s alerts to a space, shows the address once, and turns them off', async () => { const withSpaces = { ...org, spaces: [{ id: 'S1', name: 'growth' }] } as unknown as OrgWithSpaces - listing = [{ agent: cal, keys: [key('k5', 'cal')], credential: { hint: '…ab12', setBy: 'me', setAt: '2026-10-03T09:00:00Z' } }] + listing = [ + { agent: hermes, keys: [key('k1', 'hermes')] }, + { agent: cal, keys: [key('k5', 'cal')], credential: { hint: '…ab12', setBy: 'me', setAt: '2026-10-03T09:00:00Z' } }, + ] render() await screen.findByText('Cal') - expect(screen.getByText(/^Cal\.com · Yours · 1 key/)).toBeInTheDocument() + // Bots are listed apart from agents, and labelled as bots. + expect(within(screen.getByRole('region', { name: 'Agents' })).getByText('Hermes')).toBeInTheDocument() + expect(within(screen.getByRole('region', { name: 'Bots' })).getByText('Cal.com bot · Yours · 1 key')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /Cal/ })) fireEvent.change(await screen.findByLabelText('Space for alerts'), { target: { value: 'S1' } }) - listing = [{ ...listing[0]!, hook: { spaceId: 'S1', setBy: 'me', setAt: '2026-10-03T10:00:00Z' } }] + listing = [listing[0]!, { ...listing[1]!, hook: { spaceId: 'S1', setBy: 'me', setAt: '2026-10-03T10:00:00Z' } }] fireEvent.click(screen.getByRole('button', { name: 'Get address' })) await waitFor(() => expect(invoke).toHaveBeenCalledWith('spaces:setAgentHook', { orgId: 'org-1', agentId: 'cal', spaceId: 'S1' })) fireEvent.click(await screen.findByRole('button', { name: `Copy URL` })) diff --git a/apps/x/apps/renderer/src/components/spaces/agents-dialog.tsx b/apps/x/apps/renderer/src/components/spaces/agents-dialog.tsx index 1826cb2d4..f4ea39a92 100644 --- a/apps/x/apps/renderer/src/components/spaces/agents-dialog.tsx +++ b/apps/x/apps/renderer/src/components/spaces/agents-dialog.tsx @@ -9,7 +9,7 @@ import { AgentLogo, ConnectAgent } from '@/components/spaces/agent-setup' import { MemberAvatar } from '@/components/spaces/atoms' import { refreshOrgRoster, useOrgRoster } from '@/hooks/use-space-members' import type { OrgWithSpaces } from '@/hooks/use-spaces' -import { AGENT_SETUPS, agentLabel, agentSetup, kindInfo, setupFor, type AgentSetup } from '@/lib/agent-kinds' +import { AGENT_SETUPS, agentLabel, agentSetup, isBot, kindInfo, setupFor, type AgentSetup } from '@/lib/agent-kinds' import { toast } from '@/lib/toast' import { cn } from '@/lib/utils' @@ -25,10 +25,14 @@ import { cn } from '@/lib/utils' // Custom); connecting it with its new key, by the steps its setup gives; and // its page (agent-page.tsx): its option defaults, its setup steps any time // after, its keys, and a platform agent's key (Replicas) with Replace. +// +// Bots (2026-10-03, Arjun): PostHog and Cal.com run a service's commands, as +// Slack bots do, so the list shows them apart and Add bot offers them; Add +// agent offers the rest. To Harbor both are agent members. type Screen = | { name: 'list' } - | { name: 'add' } + | { name: 'add'; bot: boolean } | { name: 'connect'; agentId: string; agentName: string; secret: string; setupId: string; kind: string } | { name: 'agent'; agentId: string } @@ -131,8 +135,10 @@ export function AgentsDialog({ org, open, onOpenChange }: { const [nameEdited, setNameEdited] = useState(false) const [credential, setCredential] = useState('') const [addError, setAddError] = useState(null) - // Until the person picks, the first way to connect, and its first kind. - const setup = AGENT_SETUPS.find((k) => k.id === chosen) ?? AGENT_SETUPS[0]! + // Add bot offers the bots, Add agent the rest; until the person picks, the first, and its first kind. + const addingBot = screen.name === 'add' && screen.bot + const offered = AGENT_SETUPS.filter((k) => (k.bot === true) === addingBot) + const setup = offered.find((k) => k.id === chosen) ?? offered[0]! const kind = chosenKind && setup.kinds.includes(chosenKind) ? chosenKind : setup.kinds[0]! // The name suggests the setup's own, or the kind's, until the person types one. const shownName = nameEdited ? name : setup.defaultName || kindInfo(kind).label @@ -154,14 +160,14 @@ export function AgentsDialog({ org, open, onOpenChange }: { void load() }, [open, load]) - const startAdding = () => { + const startAdding = (bot: boolean) => { setChosen(null) setChosenKind(null) setName('') setNameEdited(false) setCredential('') setAddError(null) - setScreen({ name: 'add' }) + setScreen({ name: 'add', bot }) } // A refused platform key shows under its field, not as a toast: nothing was created. @@ -183,7 +189,7 @@ export function AgentsDialog({ org, open, onOpenChange }: { await load() setScreen({ name: 'connect', agentId: agent.id, agentName: agent.displayName, secret: key.secret, setupId: setup.id, kind }) } catch (err) { - setAddError(err instanceof Error ? err.message : 'Could not add the agent') + setAddError(err instanceof Error ? err.message : `Could not add the ${addingBot ? 'bot' : 'agent'}`) } finally { setBusy(false) } @@ -195,50 +201,65 @@ export function AgentsDialog({ org, open, onOpenChange }: { {screen.name === 'list' && ( <> -
+
{agents === null ? (
Loading agents…
) : agents.length === 0 ? (
-
{isAdmin ? 'No agents in this server yet' : 'You have no agents yet'}
-
Add one to connect Hermes, a coding agent in Replicas, or anything that speaks the agent contract.
+
{isAdmin ? 'No agents or bots in this server yet' : 'You have no agents or bots yet'}
+
Add an agent to connect Hermes, a coding agent in Replicas, or anything that speaks the agent contract, or a bot for PostHog or Cal.com.
) : ( -
    - {agents.map(({ agent, keys, credential }) => { - const mine = agent.ownerId === org.memberId - const live = keys.filter((k) => !k.revokedAt) - return ( -
  • - -
  • - ) - })} -
+
+ {([['Agents', agents.filter((a) => !isBot(a.agent.agentKind))], ['Bots', agents.filter((a) => isBot(a.agent.agentKind))]] as const).map(([heading, group]) => + group.length === 0 ? null : ( +
+

{heading}

+
    + {group.map(({ agent, keys, credential }) => { + const mine = agent.ownerId === org.memberId + const live = keys.filter((k) => !k.revokedAt) + return ( +
  • + +
  • + ) + })} +
+
+ ), + )} +
)}
- +
@@ -253,10 +274,14 @@ export function AgentsDialog({ org, open, onOpenChange }: { if (ready) void add() }} > -
setScreen({ name: 'list' })} /> +
setScreen({ name: 'list' })} + /> -
- {AGENT_SETUPS.map((k) => ( +
+ {offered.map((k) => ( setScreen({ name: 'list' })}>Cancel diff --git a/apps/x/apps/renderer/src/components/spaces/server-switcher.tsx b/apps/x/apps/renderer/src/components/spaces/server-switcher.tsx index a73f50093..23129be73 100644 --- a/apps/x/apps/renderer/src/components/spaces/server-switcher.tsx +++ b/apps/x/apps/renderer/src/components/spaces/server-switcher.tsx @@ -53,7 +53,7 @@ export function ServerSwitcher({ org, onOpenSpace, onMenuOpenChange }: { setMenuOpen(false) setAgentsOpen(true) onMenuOpenChange?.(true) - }}> Agents + }}> Agents and bots {/* The dialogs are hosted once in App (lib/server-dialog.ts); a finished one lands in the new server itself. */} openServerDialog({ kind: 'create' })}> Create a server diff --git a/apps/x/apps/renderer/src/lib/agent-kinds.ts b/apps/x/apps/renderer/src/lib/agent-kinds.ts index 541e89012..94e0ca407 100644 --- a/apps/x/apps/renderer/src/lib/agent-kinds.ts +++ b/apps/x/apps/renderer/src/lib/agent-kinds.ts @@ -33,8 +33,12 @@ export interface Logo { dark: string } -/** What an agent is underneath. Unknown kinds (from a newer Harbor) read as a generic agent. */ -export const KINDS: Record = { +/** + * What an agent is underneath. Unknown kinds (from a newer Harbor) read as a generic agent. + * `bot`: the app calls it a bot (2026-10-03, Arjun): it runs a service's commands, as a Slack + * bot does, rather than thinking for itself. To Harbor it is an agent member like any other. + */ +export const KINDS: Record = { hermes: { label: 'Hermes', logo: { light: hermesLogo, dark: hermesLogoDark } }, 'claude-code': { label: 'Claude Code', logo: { light: claudeCodeLogo, dark: claudeCodeLogoDark } }, codex: { label: 'Codex', logo: { light: codexLogo, dark: codexLogoDark } }, @@ -44,8 +48,8 @@ export const KINDS: Record = { // Meta publishes no mark for Muse Code (2026-09-30): the generic one until it does. 'muse-code': { label: 'Muse Code' }, // Integrations (2026-10-03) show the generic mark until their official ones are added. - posthog: { label: 'PostHog' }, - cal: { label: 'Cal.com' }, + posthog: { label: 'PostHog', bot: true }, + cal: { label: 'Cal.com', bot: true }, custom: { label: 'Agent' }, } @@ -56,16 +60,21 @@ export const PLATFORMS: Record = { cal: { label: 'Cal.com' }, } -export function kindInfo(kind: string | undefined): { label: string; logo?: Logo } { +export function isBot(kind: string | undefined): boolean { + return kindInfo(kind).bot === true +} + +export function kindInfo(kind: string | undefined): { label: string; logo?: Logo; bot?: boolean } { return (kind && KINDS[kind]) || { label: 'Agent' } } -/** "Hermes", "Claude Code · via Replicas", or "Agent". */ +/** "Hermes", "Claude Code · via Replicas", "PostHog bot", or "Agent". */ export function agentLabel(kind: string | undefined, connection: string | undefined): string { const platform = connection ? PLATFORMS[connection] : undefined const what = kindInfo(kind).label - // An integration is its own platform: "PostHog", not "PostHog · via PostHog". - return platform && platform.label !== what ? `${what} · via ${platform.label}` : what + // A bot is its own platform: "PostHog bot", not "PostHog · via PostHog". + if (isBot(kind)) return `${what} bot` + return platform ? `${what} · via ${platform.label}` : what } /** Something to paste, with Copy. `secret` values read as their ends on screen and copy whole. */ @@ -120,6 +129,8 @@ export interface AgentSetup { docsUrl?: string /** Opens the owner's DM with the agent before setup, for `homeChannel`. */ wantsHomeChannel?: boolean + /** A bot rather than an agent (KINDS `bot`): offered under Add bot. */ + bot?: boolean /** The service can post alerts through the agent (Harbor spec §8 Alerts): where its address goes, on the agent's page. */ alerts?: { note: string } setup: (ctx: SetupContext) => SetupRoute[] @@ -304,7 +315,7 @@ function replicasSetup({ orgUrl, agentKey }: SetupContext): SetupRoute[] { ] } -// An integration (Harbor spec §8 Integrations, 2026-10-03) needs nothing on the service's side +// A bot (Harbor spec §8 Integrations, 2026-10-03) needs nothing on the service's side // beyond its key: it answers commands, and alerts are set up on its page. function integrationSetup(name: string, example: string): () => SetupRoute[] { return () => [ @@ -318,7 +329,7 @@ function integrationSetup(name: string, example: string): () => SetupRoute[] { }, { title: 'Send its alerts to a space (optional)', - note: "On this agent's page, under Alerts, pick a space it is in and copy the address it gives you into the service's webhook settings.", + note: "On this bot's page, under Alerts, pick a space it is in and copy the address it gives you into the service's webhook settings.", }, ], }, @@ -360,6 +371,7 @@ export const AGENT_SETUPS: readonly AgentSetup[] = [ description: 'Your PostHog project: event counts, HogQL, insights, flags and alerts', connection: 'posthog', kinds: ['posthog'], + bot: true, defaultName: 'PostHog', credential: { label: 'PostHog personal API key', @@ -378,6 +390,7 @@ export const AGENT_SETUPS: readonly AgentSetup[] = [ description: 'Your Cal.com calendar: bookings, links, open times, booking and cancelling', connection: 'cal', kinds: ['cal'], + bot: true, defaultName: 'Cal', credential: { label: 'Cal.com API key',