Routing and Metadata
When a Slack message, a GitHub issue comment, an inbound email, or another provider event arrives, ThinkWork has to do three things: figure out which thread it belongs to, resolve the Space that should own the work, and preserve enough context that the tenant platform agent can write back through the same channel it came from. This page is how that works. Routing is the seam between the harness’s I/O surface (Integrations) and its perception layer (Threads) — the moment an outside event becomes structured work. The metadata that carries through is what makes the Security operating guarantee work at the integration boundary: outbound replies route via channel-scoped tools that never need raw provider tokens, because the channel handler already holds them.
The routing question
Section titled “The routing question”Every inbound integration event answers the same three routing questions before it becomes a thread turn:
- Does a thread already exist for this external conversation? A Slack threaded reply should land in the same ThinkWork thread as the original message, not a new one.
- If yes, is that thread still open? A reply to a
DONEthread should reopen it, not silently drop. - If no thread exists, which Space should receive the new one? The inbound event may name a Space directly, as email addresses do, or the tenant may have a routing rule.
Each integration runtime runs through those questions before creating or updating a thread. The answers live in channel-specific metadata.
What metadata carries
Section titled “What metadata carries”Every thread has a metadata JSON column. Channel determines what that column holds.
{ "teamId": "T0123ABCD", "channelId": "C0456EFGH", "threadTs": "1708123456.001100", "userId": "U0789IJKL", "messageTs": "1708123456.001100"}threadTs is the Slack-native thread identifier — the first message’s timestamp. Every subsequent message in that Slack thread carries the same threadTs, which is how the integration finds the existing ThinkWork thread on reply. If threadTs is absent (the user sent a top-level message, not a threaded reply), the integration either matches to a same-channelId open thread or creates a new one.
GITHUB
Section titled “GITHUB”{ "repoFullName": "acme/widgets", "issueNumber": 42, "eventType": "issue_comment", "author": "octocat"}(repoFullName, issueNumber) is the identity key. A comment on issue #42 maps to the ThinkWork thread keyed on that pair.
{ "fromAddress": "customer@example.com", "toAddress": "support@acme.thinkwork.ai", "spaceId": "sp_123", "subject": "Re: Deploy failing", "messageId": "<abc@example.com>", "inReplyTo": "<xyz@example.com>", "references": ["<xyz@example.com>", "<parent@example.com>"]}SES inbound lookup first resolves the Space from <space-slug>@<tenant-slug>.thinkwork.ai, then uses inReplyTo and references to stitch replies into existing threads via standard email threading rules. A reply to a closed thread reopens it.
{ "provider": "linear", "externalTaskId": "task_abc123", "latestEnvelope": { /* provider-normalized task state */ }}Task integrations update the thread’s metadata on every external state change so the mobile task card always reflects current task state without needing a round-trip to the external system at render time.
AUTO and CHAT
Section titled “AUTO and CHAT”Internal channels have simpler metadata:
AUTOcarries thescheduleIdortriggerIdthat fired, plus the Step Functions execution ARN for the run.CHATcarriescreatedByUserIdand, for threads started from the mobile app, the selected Space id.
How to configure it
Section titled “How to configure it”Routing is configured per integration in the relevant admin surface, such as Webhooks for custom inbound events. The thread side of routing — which Space owns a new thread, what channel a thread starts in, and what metadata follows it — is downstream of the integration configuration.
What’s tenant-scoped (admin web)
Section titled “What’s tenant-scoped (admin web)”- Target Space per integration. Every integration that creates work needs a Space. When inbound routing finds no more-specific match, the new thread routes there and invokes the tenant platform agent in that Space context.
- Routing rules per integration. A list of predicate-to-target rules evaluated in order. Predicates can be:
@-mention,channel = #engineering,from-domain = customer.com,label = bug, or provider-specific fields. Define rules in the provider’s admin surface. The first matching rule wins; if none match, the integration’s target Space runs. - Reopen behavior. Whether a closed thread should reopen on a new inbound event in the same external conversation is on by default and channel-aware (Slack
threadTs, GitHub issue id, EmailinReplyTochain). To disable per-channel — e.g., to force a new thread on every email even ifinReplyTomatches — set the integration’s reopen mode todisabled. - Signing secret rotation. Each integration’s webhook signing secret lives in the tenant credential vault. Rotate it from the provider’s admin surface; active integration runtimes pick up the new value from deployed configuration. See Integrations → credential vault.
What’s deployment-scoped (Terraform)
Section titled “What’s deployment-scoped (Terraform)”- The metadata schema per channel. Each channel’s
metadataJSON shape is enforced at the application layer by TypeScript types in the integration + outbound handler. Adding a field to existing channel metadata is a code change, not configuration. New channels require the three-seam pattern in Lifecycle and Types → Channel set. - Webhook ingress paths. The API Gateway route per integration (e.g.,
/integrations/slack/webhook) is wired in Terraform. Adding a new integration means a new route + Lambda; not a knob.
A worked example: Slack → thread
Section titled “A worked example: Slack → thread”Here’s the full path of an inbound Slack message becoming a thread turn.
- Slack fires a webhook. Slack POSTs an
event_callbackto/integrations/slack/webhookon API Gateway. - Webhook signature verified. The Slack integration runtime validates the
X-Slack-Signatureheader against the tenant credential vault. - Event normalized. The raw Slack event (nested, full of implementation detail) is mapped to a
NormalizedEvent— the integration-agnostic shape the thread path consumes. - Routing check. The integration queries
threadsfor an open thread withchannel = 'SLACK'andmetadata->>'threadTs' = $1. If present and status is notDONE/CANCELLED, reuse. If present and closed, reopen. If absent, mint a new thread. - Target resolution. For a new thread, the Lambda picks a Space target: the target of an @-mention, a channel binding, or the integration default. For an existing thread, the selected Space already owns the work.
- Message persisted. A new
thread_messagerow lands with role=user, the Slack user’s name, and the full Slack event stored inmetadatafor audit. - Tenant platform agent invoked. The managed runtime is fired asynchronously. It sees the thread, the new message, the selected Space context, and the channel-specific metadata.
- Reply or delegation. The platform agent handles the work or delegates to a folder specialist. The outbound Slack handler reads
threadTsfrom thread metadata and posts the reply in the same Slack thread.
Every step is observable in the admin thread detail view — the inbound Slack event is visible in turn metadata, Space context is visible on the thread, tool calls and memory reads are recorded, requester identity is preserved, and the outbound Slack response is logged with its Slack message id.
Why the tenant agent sees metadata
Section titled “Why the tenant agent sees metadata”When ThinkWork assembles context for a managed turn, it includes thread metadata as structured context. That’s how a reply_via_slack tool knows to post into threadTs = "1708..." without the agent itself having to parse or pass around raw provider state.
This is the system boundary made useful: the worker is channel-agnostic in its reasoning, but channel-aware in its tools.
Known limits
Section titled “Known limits”- No cross-channel routing rules. A rule like “route any GitHub issue mentioning
@on-callto the ops Space and the PagerDuty integration” requires two separate routing setups today. - Thread merging is manual. If a customer emails and then follows up on Slack, the two threads stay separate. An operator can mark one as a duplicate, but the system doesn’t auto-merge.
- Webhook replay isn’t idempotent at the event level. A integration Lambda deduping on provider-specific event ids is the right fix; today it happens inconsistently across providers.
Related pages
Section titled “Related pages”- Threads (overview) — the universal container
- Spaces — Space-scoped context and runtime policy
- Lifecycle and Types — status, channel, priority
- Integrations — the integrations that produce these events
- Admin: Threads — where metadata is visible in the UI
Under the hood
Section titled “Under the hood”Inbound integration handlers live in packages/api/src/handlers/ (one per provider — e.g., github-app.ts, email-inbound.ts). Each verifies the provider’s signature, normalizes the event into a shared shape, and calls the same internal thread-resolution path before appending or creating.
The metadata column is JSONB — no Postgres-level schema — with shape enforced at the application layer by the TypeScript types the integrations and outbound reply handlers share. Outbound handlers read from the same metadata to decide where to post a reply.