Skip to content

Talk to an OpenBot coworker from Slack, as yourself - #297

Open
jerelvelarde wants to merge 3 commits into
CopilotKit:mainfrom
jerelvelarde:jerel/slack-coworkers
Open

Talk to an OpenBot coworker from Slack, as yourself#297
jerelvelarde wants to merge 3 commits into
CopilotKit:mainfrom
jerelvelarde:jerel/slack-coworkers

Conversation

@jerelvelarde

Copy link
Copy Markdown
Contributor

Talk to an OpenBot coworker from Slack

The problem

A coworker only exists where OpenBot is open. The work it is for happens somewhere else: the thread
where somebody asks whether the filing is clean, the channel where a question about a customer lands
at eleven at night. Getting a coworker to answer there means a person reading the question, opening
OpenBot, retyping it, and pasting the answer back — which is a person doing the routing, badly, and
the reason most of these questions never reach a coworker at all.

The obvious way to close that is a bot that posts into Slack on the deployment's behalf. It is also
the wrong one, and the reason is the whole of this change. A shared bot answers as itself. It cannot
say which person asked, so it cannot read that person's roster, apply that person's grants, refuse a
private coworker they cannot see, or put their name on the audit row. Every safety property this
deployment has is a property of knowing who is asking, and a bridge that forgets loses all of them
at once while still looking like it works.

The approach

Channels SDK owns Slack; this deployment owns everything that decides. One createChannel
declaration named openbot is handed to the same CopilotRuntime the browser talks to. Ingress,
subscriptions, delivery, streaming, files, deduplication and reconnects are the SDK's. Which coworker
runs, whether this person may run it, which tools it holds and what gets written down are not.

Every turn re-resolves the speaker. SlackIdentityLinker maps the Slack workspace and user to an
OpenBot user through external_user_links, and the run is built for that actor by the same
ActorAgentResolver a browser request uses. The conversation is shared by the thread; authorization
is not. A second person in the thread who cannot see the pinned coworker is refused with a plain
sentence — not run as the person who started it, not silently rerouted, and not told what the
coworker is configured to do.

The thread is pinned to one coworker, once. external_thread_bindings is keyed by the canonical
Channels thread id and is append-only, enforced by a trigger rather than by the code that writes it,
because a binding that can be edited is a conversation that can be aimed somewhere else after the
fact. Wanting a different coworker means a new top-level mention, which is a new thread.

An unlinked person is told so, and the agent does not run. They get a signed, expiring link to a
confirmation page behind their own OpenBot session, which binds only to the user completing the flow.
An exact match between a verified Slack email and one active OpenBot account may create the first
link. Ambiguous or conflicting matches require the explicit flow, and an existing link is never
reassigned by a later email match.

Secrets are never asked for in Slack. When a coworker needs a sign-in, a code or a card number,
the thread gets a sentence and an expiring link to that coworker's own screen in OpenBot. The bounded
assistance wait continues on the server and resumes when control is released there, or ends cleanly
when it is cancelled or expires.

The computer is the same computer. Slack turns call the tools declared in
shared/computer-tool-contracts.ts — the same contract the browser registers — and they execute
through ComputerGateway. The same policy decides, the same refusal comes back, the same audit row
is written. There is no second, quieter path to an acting call.

Where it runs

  • New state: four Postgres tables — external_user_links, external_thread_bindings,
    external_thread_messages, approval_decisions — in migration 0024_slack_channels.sql.
  • Second replica: a reply delivered to any replica reads the same binding and the same transcript
    by canonical thread id, and answers into the same Slack thread.
  • Serialised: a unique index on (provider, tenant, conversation, thread) for bindings and on
    (thread, message_id) for transcript rows, so a redelivered Slack event cannot bind twice or store
    a message twice. Same-thread turns are configured serial.
  • In memory on purpose: SlackIngressRegistry holds identity facts for one delivery, keyed by
    Slack event id, one-use, 30-second TTL, and returns nothing unless exactly one entry matches. Both
    halves — the SDK's identifyUser callback and the agent factory — run in the same process on the
    same delivery, so this is not cross-request state. It fails closed: no match means no run.
  • New listener: none. Managed delivery arrives on an outbound socket this process opens, so
    nothing new is exposed through the ingress. startManagedChannelHost starts HTTP first so setup
    and health stay reachable while attachment settles, and /api/capabilities projects three fields
    of channel status — never the snapshot, which carries the provider's own token.

What is not covered

  • One Slack identity for the deployment, not one per coworker. Separate mentionable bot users need a
    Slack app and a credential lifecycle each.
  • A Slack thread cannot change its coworker.
  • The OpenBot-side transcript is read-only. Composing there does not reach Slack.
  • Arbitrary OpenBot components do not render in Slack. Only approvals and assistance have an
    intentional Slack representation; everything else degrades to text.
  • Automatic email linking depends on the Slack profile carrying a verified email. Where it does not,
    every person links explicitly.
  • server/package.json takes @copilotkit/channels, the umbrella package, which pulls the Discord,
    Telegram, Teams and WhatsApp adapters in with it. Only Slack is used. Narrowing to the
    sub-packages is a small change and I will make it if you would rather not carry them.

Verification

Full suite against a live PostgreSQL: 2432 pass, 0 fail, 23 skip, 2455 tests across 195 files —
main in the same environment runs 2084 across 170. bun run format:check, bun run lint, bun run typecheck and bun run build clean. drizzle-kit check reports no collision, and the
unwritten-migration probe finds nothing to generate.

The recording is the deployment we run this on, on 28 August: a mention in a Slack channel, the
coworker browsing a page and answering in-thread with what it read, the deep link back, and then the
same conversation in the OpenBot sidebar with its stored transcript. It is the fork's build of this
change; the branch has since been rebased onto current main, which is what the numbers above are.

New test files, one line each:

  • slack-channel.integration.test.tsx — mention, reply, binding, and the refusals: an unlinked
    speaker, a second speaker who cannot see the coworker, a coworker deleted after binding.
  • slack-identity-linker.test.ts — email matching, ambiguity, and that a link is never reassigned.
  • slack-computer-tools.test.ts — every computer tool through the gateway, and its refusal.
  • slack-assistance.test.ts — the assistance link, the bounded wait, resume, cancel and expiry.
  • slack-channel-agent.test.ts — binding, delegation, and that private context never reaches a prompt.
  • slack-ingress-registry.test.ts — one-use, TTL, and refusing an ambiguous match.
  • slack-approval-authorizer.test.ts, slack-approval-store.integration.test.ts — who may decide an
    approval, and that a decision is recorded once.
  • slack-tenant-context.test.ts, slack-turn-phase.test.ts, slack-execution-context.test.ts
    canonical tenant, turn phase, and the per-run context boundary.
  • slack-lifecycle.test.ts — HTTP up before attachment, and still up when attachment fails.
  • external-link-store.integration.test.ts, external-link-token.test.ts,
    external-link-routes.test.ts — the link table, the signed token, and the confirmation routes.
  • external-thread-store.integration.test.ts — bindings, transcript ordering, and the append-only
    trigger.
  • app/tests/* — the link page, the assist route, the sign-in return, the sidebar rows and the
    read-only thread view.

Merge notes

This is based on #296, so that change's commit is in this branch too and its diff shows here as
well; review from the second commit. #296 lands first. createApp and mountCopilotRuntime both take new trailing arguments, and
mountCopilotRuntime takes the resolver in place of its eleven collaborators, which is the shared
contract most likely to collide with another branch in flight.

Four callers now build a Bot for a person — a chat request, a routine's
headless turn, a hop delivered to another Bot, and the boundary's own
lookup — and each passed the same eleven collaborators positionally. One
of them getting an argument wrong is a Bot that runs and quietly holds
different tools or a different role from the one the person is talking
to. ActorAgentResolver binds them once.

Choosing a coworker moves out of the HTTP route for the same reason: it
was the routing model call, the visibility rule, and the channel.routed
row all written inside a Hono handler, so nothing that is not an HTTP
request could route. CoworkerRoutingService owns the decision, and the
route turns its outcome into status codes.

That move makes an explicit name cheap enough to honour: a message that
names exactly one coworker on the asking person's roster no longer pays
a model call to be told what the person already said. Two matches are
refused with both names rather than guessed at.
A person mentions @openBot in a Slack thread and names or describes the
coworker they want. The thread is pinned to that coworker and replies
continue with it, without another mention.

Channels SDK owns Slack ingress, delivery, streaming and files. This
deployment stays the authority for everything that decides what may
happen: every turn re-resolves the Slack speaker to an OpenBot user and
reloads THAT person's roster, grants, policy and audit identity. A
second person in the same thread who cannot see the pinned coworker is
refused rather than run as the person who started it.

The coworker is built by the same resolver a browser turn uses, so a
Slack turn holds the same tools, the same standing role, the same
signed run assertion and the same stall guard. Its computer runs
through the same gateway, which means the same boundary decides, and
the same audit row is written.

Secrets, sign-in control and 2FA are never asked for in Slack. The
thread gets an expiring link to this deployment's own screen, and the
bounded assistance wait resumes when control is released there.

An unlinked Slack user is told so and handed a signed, expiring link;
the agent does not run. An exact match between a verified Slack email
and one active OpenBot account may create the first link. Nothing
already linked is ever silently reassigned.

State lives in Postgres, not in the process: the thread binding, the
transcript, the identity link and the approval decisions are all
tables, so a reply delivered to a second replica finds the same
conversation. The bindings table is append-only by trigger.
The Slack side of a conversation was only in Slack: a person could not
read what their coworker had done, and the account link and the secure
prompt a Slack turn sends somebody to had nowhere to land.

Three surfaces, all behind the existing session guard. Confirming a
Slack account is theirs happens on a page that reads the signed link
token and binds only to the OpenBot user completing the flow, with a
sign-in return that comes back to the same confirmation rather than the
roster. Taking the wheel or answering a secure prompt happens on the
coworker's own screen, reached from the expiring link in the thread.
And a Slack thread appears in the conversation sidebar, labelled, next
to the channels it already lists, opening a read-only transcript of the
turns as they were stored.

The computer tools a Slack turn calls are declared once, in shared, so
the browser and the channel offer the same contract rather than two
drifting copies of it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant