Skip to content

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.

Every inbound integration event answers the same three routing questions before it becomes a thread turn:

  1. 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.
  2. If yes, is that thread still open? A reply to a DONE thread should reopen it, not silently drop.
  3. 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.

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.

{
"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.

Internal channels have simpler metadata:

  • AUTO carries the scheduleId or triggerId that fired, plus the Step Functions execution ARN for the run.
  • CHAT carries createdByUserId and, for threads started from the mobile app, the selected Space id.

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.

  • 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, Email inReplyTo chain). To disable per-channel — e.g., to force a new thread on every email even if inReplyTo matches — set the integration’s reopen mode to disabled.
  • 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.
  • The metadata schema per channel. Each channel’s metadata JSON 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.

Here’s the full path of an inbound Slack message becoming a thread turn.

  1. Slack fires a webhook. Slack POSTs an event_callback to /integrations/slack/webhook on API Gateway.
  2. Webhook signature verified. The Slack integration runtime validates the X-Slack-Signature header against the tenant credential vault.
  3. Event normalized. The raw Slack event (nested, full of implementation detail) is mapped to a NormalizedEvent — the integration-agnostic shape the thread path consumes.
  4. Routing check. The integration queries threads for an open thread with channel = 'SLACK' and metadata->>'threadTs' = $1. If present and status is not DONE/CANCELLED, reuse. If present and closed, reopen. If absent, mint a new thread.
  5. 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.
  6. Message persisted. A new thread_message row lands with role=user, the Slack user’s name, and the full Slack event stored in metadata for audit.
  7. 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.
  8. Reply or delegation. The platform agent handles the work or delegates to a folder specialist. The outbound Slack handler reads threadTs from 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.

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.

  • No cross-channel routing rules. A rule like “route any GitHub issue mentioning @on-call to 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.

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.