Skip to content

Channel assistant

One shared assistant that lives in a Slack channel. Anyone can @mention it to ask a question or delegate work; it carries channel-scoped memory, tools, budget, and persona, and can act proactively. It is built on the platform’s existing pieces — the agent resolver, semantic memory, MCP tool binding, the Slack app, and org/team RBAC — rather than a parallel stack.

It plays two roles at once, and both matter. It is a capability a team uses directly — answering questions with repo and run context, summarising a thread, tracking open items — and it is the platform’s conversational control surface: workflows are started from a run modal or the “Run a workflow” shortcut (§9), progress is reported in-channel, HITL gates are answered with Slack buttons (hitl-workflows.md), and workflow drafts are refined in a thread (nl-workflow-authoring.md). Teams manage their automation from the channel they already work in rather than a separate console.


Model Purpose
SlackWorkspace A connected workspace (slackTeamId), owned by one Organization. Carries the per-workspace bot token (encrypted) plus appId / botUserId / installedAt from the OAuth install.
SlackChannel A channel the assistant is resident in. Unique on (workspaceId, slackChannelId). See the field groups below.
ChannelThreadSession lastAssistantAt per (channelId, threadTs) — the freshness anchor for follow-up sessions.
ChannelOpenItem A tracked open item, deduped by sourceTs. ChannelOpenItemStatus is OPEN → RESOLVED or DISMISSED; lastNudgedAt rate-limits stale-item nudges.
ChannelMonthlyUsage Per-channel monthly cost ledger, unique on (channelId, yearMonth).
ChannelBudgetHold One in-flight claim on that ledger — the row that makes a hold reversible when the worker holding it dies. Swept past expiresAt.
MemoryItem Gains channelId / teamId / orgId for channel-scoped memory.
Agent Gains channelId for CHANNEL-scoped rows; partial-unique (key, version, channelId) WHERE scope='CHANNEL'.

SlackChannel fields group by concern:

Concern Fields
Identity & routing agentKey (the driving Agent), teamId (RBAC + team cascade tier), orgId (denormalized for memory and budget)
Proactivity ambientEnabled / ambientCron, reactiveEnabled / reactiveCron, lastReactiveCheckAt / lastReactiveAt, orgFlaggingEnabled / lastOrgFlagCheckAt
Cooldown tuning reactiveCooldownMinutes, reactiveLookbackMinutes, orgFlagCooldownHours, openItemNudgeAfterHours, openItemNudgeCooldownHours — all nullable; null means the worker’s built-in default
Memory passiveIngestEnabled / passiveIngestCursor, consolidationEnabled / consolidationMinClusterSize / consolidationSimilarityThreshold, isPrivate
Conversation personaPrompt, followupSessionEnabled
Spend monthlyBudgetUsdCents

ConfigScope carries a CHANNEL value, giving the agent resolver five tiers:

WORKFLOW_TEMPLATE → CHANNEL → TEAM → ORGANIZATION → GLOBAL

The CHANNEL tier fires only when ctx.channelId is set, so non-Slack runs are unaffected. Provider credentials deliberately keep GLOBAL / ORGANIZATION / TEAM scopes only, enforced by a DB CHECK — there is no channel-level credential tier. CHANNEL-scoped agent rows are created from the Slack admin surface rather than the generic scope dropdowns.


An @mention starts a ChannelAssistantWorkflow that replies in-thread, with live progress via chat.update while it works. Each turn fetches the thread’s prior messages through conversations.replies and prepends them as context, so a thread reads as one conversation. DMs (message.im) are handled for anything a user would rather not ask in public.

Persona. SlackChannel.personaPrompt falls back to Team.defaultPersonaPrompt via resolvePersonaPrompt — a pure synchronous cascade in lib/channelPersona.ts. The result is injected at the top of the system prompt, before tool hints, across every channel LLM path. Editable per channel on the channel’s Settings tab (/admin/slack-channels/[id]) and team-wide at /teams/[id].

Follow-up sessions. With followupSessionEnabled, a plain reply continues a thread without a re-@mention while ChannelThreadSession.lastAssistantAt is fresh (a 30-minute window). Follow-up turns pass through an addressed-to-me intent gate: a turn the model judges is not directed at the assistant returns suppressed and posts nothing. Steering a running task always takes precedence over conversational follow-up.

Workflow drafts. The generateWorkflow and refineWorkflow tools only record an intent: a request to create an automation becomes a draft template, and a follow-up in the thread refines that draft. After the turn, fixed workflow code performs the generation or refinement and saves a DRAFT template (or a new, unpromoted version), which is never active until a person activates it. Both paths run with allowShell: false, and a spec that runs code an author wrote is refused: a shell or containerStep node, a step naming runContainerStep, or a command set on a gate step, a runQualityGates gate or an eval gate scorer. The author agent is asked to repair such a spec before it is refused; a refusal replies with a pointer to the canvas in the Workflow library, where that part is authored. A channel-authored workflow never contains them. The draft is budget-gated like a delegated task. See nl-workflow-authoring.md for the authoring paths and their limits.

Injection scanning. Channel input is scanned advisorily and surfaces as a CHANNEL_SUSPICIOUS security event in /admin/security.


Delegating with an @mention launches a durable, thread-bound RunnableWorkflow. There are two routes:

  • Code route — uses the team’s SWE template and behaves like any engineering run, visible to everyone who can reach the repository. From a private channel the acknowledgement starts with a note saying so: the channel’s privacy does not extend to the task, its run or its pull request.
  • General route — runs the seeded “Channel Task” template: plan → cond → {single | composite}. A planChannelTask step, deliberately biased against splitting, returns 1..N subtasks. A cohesive task runs a single channelAssistant node; a splittable one runs runChannelSubtasks, which does bounded-concurrency subtask fan-in plus synthesis inside one activity. finalizeChannelTaskRun reads nodes.composite.output.text ?? nodes.task.output.text.

The general route is repo-less, so its run has no ActiveWorkflow row for the ambient context to derive a team from. Its agent nodes, planner and fan-out therefore take the channel’s own team and org (withChannelScope), which puts a TEAM or ORGANIZATION override of channelAssistant in force for the delegated task exactly as it is for the reply. This is resolution only: it grants nothing. The same scope reaches runAgent, so a TEAM- or ORGANIZATION-scope workspace.agentMaxSteps (the setting has no CHANNEL tier) bounds the tool loop of mention turns and delegated tasks.

Not an agent run. A delegated task is a RunnableWorkflow; it is not a library agent run, which is a repository-bound, container-bound step that requires an ENGINEER launcher. The general route has no repository, and the code route launches the team’s engineering template. channelAssistant is on the agent-run non-launchable list. See agent-runs.md §9.

The fan-in lives inside one activity rather than using a fanOut node because the interpreter cannot surface a branch agent’s text back to a join.

Launch outcome. The agent’s “on it” reply is posted only when a task actually started. When nothing did, the thread gets a message in its place: the thread already hosts a task (its per-thread workflow id is single-use), the requester was refused on access, or the launch failed for any other reason (“I couldn’t start that task”). A launch failure never fails the turn itself.

Run pins. Each turn’s own WorkflowRun (startChannelRun) pins the latest active GLOBAL Agent versions, the runtime each agent resolves to at the channel’s scope (channel, team and organization overrides included), and the revisions of the skills visible to the channel’s team and organization, as createWorkflowRun does for other runs, so a retried turn activity resolves the same agent, loop and skill text.

Steering. A reply in the task’s thread sends a steer signal to the running workflow, which drainSteering picks up and prependSteering folds into the next agent call — mid-task hand-off without restarting.

Deferral. A task given a runAt is wrapped in ChannelScheduledTaskWorkflow. The wrapper stays alive after firing, holding the child handle so a fired deferred run remains steerable from the thread.


Four best-effort activities ride a per-channel ambient Temporal Schedule, plus a separate reactive schedule. All are opt-in, budget-gated, and SKIP-aware — a model reply beginning with skip posts nothing.

Mode Trigger Behaviour
Ambient digest ambientCron Posts a proactive channel digest
Reactive interjection reactiveCron Polls conversations.history from an exclusive cursor and decides whether to chime in. Four gates: new human messages since the cursor, budget, a cooldown since the last post, and SKIP
Open-item tracking Ambient fire sweepChannelOpenItems detects new open items, marks resolved ones, and nudges stale ones. Deduped by sourceTs
Org-wide flagging Ambient fire flagOrgSignals embeds the channel’s recent memory once, searches org channels (excluding private sources), and flags cross-org signals against a high bar

Org-wide flagging advances its 20-hour cooldown once the embedding and search have run — whether it flagged, skipped, or found nothing, and stamped before the LLM call — so a rarely-flagging channel does not re-pay the embedding and search on every fire. Its cost accrues with countRun: false.

Proactivity is conservative by default: org-wide flagging is per-channel opt-in, never reads private channels, and is hard rate-limited, so channel isolation holds unless an admin deliberately relaxes it.


Channel memory is ordinary MemoryItem rows scoped by channelId / teamId / orgId, retrieved by the same pgvector search as everything else.

  • Retrieval — retrieveChannelMemory reads the channel’s own memory and sibling channels on the same team, auto-injected as turn context.
  • Cross-channel reads are bounded twice. searchTeamChannelMemory and searchOrgChannelMemory filter on the reading channel’s team_id / org_id, and join slack_channels to exclude is_private = true sources. A private channel’s memory is never a source for another channel, and no read crosses an org.
  • isPrivate comes from Slack. At provision time the gateway calls conversations.info for the authoritative is_private. When Slack cannot answer — no token, no groups:read scope, a network failure — it falls back to the payload heuristic (channel_type: 'group' on the events path, a G-prefixed id on the shortcut path). The flag is written on CREATE and is admin-editable afterwards.
  • The Slack channel sync re-asks. With SLACK_CHANNEL_SYNC_ENABLED, a scheduled sweep (ScheduledSlackChannelSyncWorkflow, lib/slackChannelSync.ts) calls conversations.info for every active channel and turns isPrivate on for one Slack now reports private, with an audit row. It never turns the flag off: an admin may have marked a public channel private on purpose. For each private channel it then replaces the channel’s SlackChannelMember rows with Slack’s conversations.members list and stamps membersSyncedAt; a public channel’s rows are dropped. A list that cannot be read whole — a Slack error, a missing scope, more than 10,000 members — leaves the previous rows and timestamp as they were, and they age out. Channels are synced one at a time against the bot token, and the sweep’s result and logs carry ids, counts and Slack’s error codes, never member ids. It needs the channels:read and groups:read bot scopes (slack-app-setup.md).
  • Passive ingestion — with passiveIngestEnabled, passiveIngestChannelMemory silently extracts up to five salient facts from human messages on each ambient fire, advancing a passiveIngestCursor and de-duplicating at 0.85 similarity. Accrues with countRun: false.
  • Consolidation — consolidateChannelMemory clusters and merges related items on each ambient fire, honouring the per-channel consolidationEnabled toggle and optional cluster-size and similarity overrides.
  • Memory is gated against prompt injection. Memory is replayed into every later turn that recalls it, so it is held to a stricter rule than the advisory input scan: the memory gate (lib/memoryGuard.ts, shared with SWE lessons — see agents.md §6.4) refuses a write whose text matches an INJECTION pattern and drops a matching item on recall. A summary the gate refuses is not retried as the raw exchange. Recalled items reach the model inside a <recalled_memory> fence that says they are reference data, not instructions — in a mention turn, a reactive interjection, an ambient digest, and both lists of an org-flagging check.

A private channel’s conversations are for the people in it. A channel’s memory, open items and audit feed are readable by members of its owning team. On a channel with isPrivate set they take more, because team membership is not membership of the Slack channel: a platform ADMIN, or a member of the owning team whose linked Slack account (User.slackId, re-read on every request) is in the channel’s SlackChannelMember list, while its membersSyncedAt is within SLACK_CHANNEL_SYNC_STALE_HOURS (assertChannelContentAccess, lib/privateChannelMembers.ts). Anyone else gets 403 PRIVATE_CHANNEL with a hint — link a Slack account, or wait for the next sync. A channel the sync never read, or one whose list has gone stale, is admin-only, so turning the sync off returns every private channel to admin-only within that window. The channel’s configuration and budget stay team-visible. The same rule decides the run viewer: a turn in a private channel is one of the team’s runs only for its synced members, because its traces carry the conversation.

Admins can view, edit, and delete channel memory from the admin surface. Editing an item’s text kicks off ReembedMemoryWorkflow so its pgvector embedding catches up to the new text — started best-effort, because a Temporal hiccup must not fail the synchronous edit. Deleting an item forgets it through every merged copy and restores what only those copies were hiding (memory.md §6).

Deleting a channel deletes its memory items in the same transaction as the channel row (the relation itself is SetNull, which would otherwise leave them orphaned), and the audit entry records how many went. The single-channel read carries memoryItemCount so the confirmation can state the number.

Consolidation and lesson consolidation share clusterByEmbedding (lib/embeddingClustering.ts).


ChannelMonthlyUsage mirrors OrgMonthlyUsage and backs monthlyBudgetUsdCents. Channel spend is tracked in the channel ledger and is not double-counted into OrgMonthlyUsage.

A turn’s real cost is only known after the model answers, so a turn takes a hold against the cap before it runs and settles that hold for the true cost afterwards (reserveChannelTurn → ChannelBudgetHold.settle). The hold is an atomic increment on the ledger row, and each turn decides on the total before its own increment — so remaining headroom is a resource turns consume rather than a number they all read, which matters because turn workflow ids are per-event and a busy channel runs many at once. It is pinned to the month it was taken in, and scaled by the number of model calls the held work will make — an assistant turn holds for two (the reply and the memory summarizer that follows it, which settle against the same hold), and the passes that fan out over a batch hold for the batch size.

The hold is priced, not guessed at. estimateHoldUsd resolves the agent the channel is bound to and prices a nominal turn envelope (8K in / 1.5K out) through calculateCostUsd, the same helper the run ledger prices real calls with — so an Opus channel holds ~$0.078 per call and a Haiku channel ~$0.016, rather than sharing one number that is ~5x wrong for one of them. CHANNEL_TURN_RESERVATION_USD ($0.05) survives only as the fallback hold for a model whose agent cannot be resolved, or whose catalog price is zero, since a zero hold would bound nothing.

The settled cost is priced at the model the call was bound to: runAgent passes its resolved modelSpec to recordLlmUsage, so a CHANNEL-scope override is charged at the override’s rate. The three channel-memory passes do the same: each binds commitToMemory once through getBoundModel and settles at that bound spec.

A capped channel refuses a model with no catalog price. An unpriced model measures as $0, which would net the whole hold back off the ledger and leave the cap with nothing to count. So when a channel has a monthly budget and its bound model has no price, reserveChannelTurn refuses before anything is held or spent, with a non-retryable MODEL_UNPRICED failure naming the model and the model catalog. A mention turn replies in the thread with that message instead of the generic error text. A background pass (digest, memory, open items, flagging) catches its own error and no-ops, so the refusal appears only as a logged line ([<pass>] ... no price in the model catalog) and the feature quietly stops until the model is priced; check the worker log when ambient output, memory or flagging goes missing on a capped channel. The memory summarizer inside a mention turn runs under the channel scope, so it is guarded too; a refusal there stores no memory for the turn and spends nothing — not even the embedding a raw-exchange fallback would need. The same check runs in runAgent, so a delegated channel task refuses too (the failure surfaces from the task’s branch rather than as an empty answer, and the thread’s failure line carries the reason). An uncapped channel proceeds and records $0 with llm.cost_pricing_known=false. While the catalog itself cannot be read the refusal is a retryable MODEL_PRICE_UNAVAILABLE instead, so a database blip never tells an admin to add a price that exists.

The settle-time floor remains as a second line of defence: should a held turn still measure as unpriced (the catalog changed between reserve and settle), it settles at no less than its reservation rather than netting the hold to zero.

Every channel pass resolves its agent at the CHANNEL tier — { channelId, orgId, teamId } threaded explicitly into resolveAgent, getModel / getBoundModel and loadAgentSkills. The ambient Temporal context carries no channelId, so a pass that priced its hold at that tier and bound its model without it would charge for a channel-scoped override it never used.

With H USD of headroom, at most H / estimate calls are admitted, and each can overshoot by however far its real cost exceeds its hold — so the aggregate overshoot is bounded by admitted concurrency, not unbounded by it as a plain read gate was.

A hold is a row, not just an increment. The increment is what bounds concurrency; the ChannelBudgetHold row beside it — written in the same transaction — is what makes the claim reversible. A worker that dies mid-turn never settles, and without the row its estimate would sit on the ledger for the rest of the calendar month. Instead a sweep reclaims any row past expiresAt (CHANNEL_HOLD_TTL_MS, 30 minutes), subtracting exactly what it added. Deleting the row is the claim, so a sweeper and a settling turn racing the same hold cannot both refund it — and a turn whose hold was swept settles its full cost rather than netting against a reservation that is already gone.

The sweep runs from the refusal, not the happy path. The accrued total a gate reads includes outstanding holds, so the state the sweep exists to repair — a channel pushed over its cap by holds nobody is spending against — is exactly the state that refuses every subsequent turn. Both isChannelOverBudgetNow and reserveChannelTurn therefore sweep from inside their refusal branch, and re-decide only when the sweep actually reclaimed something. A channel under its cap never pays the extra round-trips; a channel that has genuinely spent its budget — for which refusal is the steady state, not a rare path — pays one query and no re-read.

The corollary is that a channel comfortably under its cap never sweeps at all, so its abandoned holds sit until something pushes it to the cap. POST /api/v1/platform/slack-channels/:id/budget/reset clears them on demand — API-only, with no control in the admin UI. It reports how many holds it actually released, and is deliberately not a “zero the month” button: it subtracts exactly what the holds added and leaves real spend alone, so recovering from a crash never doubles as disabling the cap.

The refund protocol has one implementation, releaseChannelBudgetHolds in @auto-swe/shared/lib/channelBudget, shared by the sweep and the reset. One transaction per hold, delete before decrement, credit the hold’s own month, never touch runsCompleted. It lives in shared because it is a data-model invariant rather than route logic: a bug there credits real spend back and quietly loosens the cap it exists to enforce.

One path still spends on the channel ledger without a hold: finalizeChannelTaskRun. A delegated task run spends across a whole workflow, not inside one activity, so there is nothing in-process to hold. isChannelOverBudgetForTask gates its launch with a plain read, and its summed cost lands on the channel ledger when the run finalizes.

isChannelOverBudgetNow is that read. It also runs as a cheap bail before a held path does any prompt-building work — it decides nothing on its own there; the hold is what enforces the cap.


Every turn and ambient digest creates a lightweight WorkflowRun (startChannelRun → turn → finalizeChannelRun) under a seeded GLOBAL “Channel Assistant” template. This gives persistActivityTrace a resolvable runId, so per-turn status, cost, tokens, and the full AgentTrace stream appear in the run viewer.

The main /runs list default-excludes both chatter templates — “Channel Assistant” and “Channel Task” — behind an opt-in toggle, so channel volume does not bury engineering runs. Code-route tasks use the team’s SWE template and stay visible like any engineering run.

Audit feed. startChannelRun stamps the triggering userSlackId and a truncated message snapshot onto specSnapshot.channel. GET /api/v1/platform/slack-channels/:id/audit aggregates the channel’s runs into a “who asked what, when, and what it touched” feed — kind, who, when, status, cost, tokens, run ID. The channel page’s Activity tab renders it with a kind filter and a link through to the full tool-call sequence; Slack user IDs show as the linked account’s email where one exists. Read access uses the same assertChannelContentAccess guard as memory and open items, so a private channel’s feed is admin-only (§6).

Channels are listed at /admin/slack-channels and configured on each channel’s page (/admin/slack-channels/[id], tabs Settings, Memory, Open items, Activity) over /api/v1/platform/slack-channels (plus /:id/budget, /:id/budget/reset, /:id/audit, and the memory and open-item sub-resources); channel-scoped agents are created from the agent-library form with CHANNEL scope and a channel picker. /:id/budget/reset has no UI control — it is called directly, by an admin who knows a worker crashed mid-turn.

Everyone else on a channel’s owning team sees the same pages read-only: the list of their teams’ channels, and each channel’s Memory, Open items and Activity tabs, without Settings and without the edit, delete, resolve and dismiss controls. Slack users show as their ids there, since the user list that names them is an administrator’s. A private channel’s tabs answer as above.

Schedule lifecycle. provisionChannel registers a channel on first contact, defaulting isPrivate from Slack’s channel_type. Toggling ambient or reactive mode calls syncChannelAmbientSchedule / deleteChannelAmbientSchedule on the gateway’s Temporal plugin, so the per-channel Temporal Schedule is created, retimed, or torn down to match the row.


Surface Detail
Install GET /api/v1/auth/slack/install[/callback] → oauth.v2.access → an encrypted per-workspace bot token. The bot token is the only per-workspace secret; the signing secret and OAuth credentials stay singleton. “Add to Slack” lives on /admin/integrations
App Home app_home_opened → publishAppHome → views.publish renders a Block Kit front door (buildAppHomeView is pure and unit-tested)
Slash /auto-swe — workflow list/show and a run modal
Shortcuts A global “Run a workflow” picker, and a message shortcut “Ask auto-swe about this” that starts a turn on the message with no account link required

All Slack I/O resolves its token by channel or workspace ID via resolveSlackBotTokenForSlackChannel / resolveSlackBotTokenForWorkspace, falling back to the singleton config.

Thread-history context requires the channels:history and groups:history bot scopes — see slack-app-setup.md for reinstall instructions.


Not yet validated in a live workspace. This is the load-bearing caveat: the capability surface below is built and unit-tested, but it has not been run against a real Slack workspace under sustained use. No code closes this — it needs an install, a pilot channel, and observation. Treat the proactivity features especially (ambient digests, reactive interjection, org-wide flagging) as unproven on real traffic, and turn them on one channel at a time.

  • The budget cap is bounded, not exact. See §7 — the hold prices a nominal turn envelope against the bound model, so a turn with an unusually long prompt or reply overshoots by the difference, and the aggregate overshoot still scales with how many calls the remaining headroom admits. finalizeChannelTaskRun spends on the ledger with no hold at all. A hold lost to a worker crash over-counts the channel until the sweep reclaims it — which only happens once the channel reaches its cap, or an admin calls /:id/budget/reset (API-only; there is no UI control).
  • An unpriced model is only refused where a USD cap applies. A channel with no monthly budget records $0 for a model with no catalog price (llm.cost_pricing_known=false), and the run’s token tiers still bind. Embedding calls are priced and accrued separately and are not covered by the refusal.
  • A channel’s turn runs are visible to its owning team, not to everyone in the Slack channel. A turn run links its channel (WorkflowRun.channelId), and run visibility admits members of the channel’s owning team — the same people who can open the channel’s settings and audit feed — and, for a private channel, platform ADMINs and the team members in its synced member list. Someone who talks to the assistant in Slack but is not on that team does not see the run in /runs. A run whose channel row has since been deleted loses the link and is visible to platform ADMINs only.
  • Slack cannot launch a library agent against a repository. The assistant answers, delegates a task, or generates a workflow; it has no route to an agent run. Adding one is a new feature with its own identity, RBAC, budget and security design (see agent-runs.md §9).
  • Reactive interjection posts at channel root, not into the most relevant thread.
  • No per-stage progress posts back into a task thread beyond the live chat.update on turns; the run itself is observable in /runs.
  • No single long-lived per-channel workflow. Turns are per-mention and ambient work is schedule-driven. Durable mid-task hand-off is already covered by thread-bound task runs, so consolidating onto one signal workflow per channel remains optional.
  • Channel input scanning is non-blocking. scanChannelInput runs the same scanSkillContent the implementer’s output scanner uses, but a warning only records a channel.suspicious_input advisory event and the turn proceeds; the whole thing is wrapped in try/catch so a scanner failure cannot abort a turn. A channel is also a lower-trust input surface than a ticket — anyone in the workspace can type into it, and a delegated task turns that text into autonomous work. The scanner coverage gaps in agents.md §11 apply here too: a bash call is checked only against SHELL_COMMAND patterns, and the write-path scanners gate the writeFile tool only.
  • The memory gate is pattern-based. It catches the phrasings the INJECTION patterns name and nothing else; a reworded instruction passes and is recalled like any other note, with only the <recalled_memory> fence between it and the model. A false positive costs the note, silently apart from a worker log line: there is no review queue for refused memory.
  • isPrivate is only as fresh as the last sync. Without the Slack channel sync (it is off by default), the flag is read once, at provision, and a channel converted to private in Slack keeps the value it was provisioned with — its memory stays a cross-channel source, and its memory, open items, audit feed and turn runs stay readable by its team — until an admin flips the flag. With the sync on, that window is one sync interval. Where Slack cannot answer, the payload heuristics still apply and neither is authoritative for every channel shape. Verify the flag on any channel holding sensitive discussion rather than trusting the default.
  • Private-channel access lags Slack by one sync. Someone removed from a private channel keeps its dashboard content until the next sync replaces the list, or until the list ages past SLACK_CHANNEL_SYNC_STALE_HOURS if the sync keeps failing; someone added waits for the next sync. Access also follows the linked User.slackId, so an account linked to the wrong Slack user sees that user’s private channels.
  • A channel made public in Slack stays private here. The sync only tightens the flag, so a private channel converted to public keeps its admin-only content until an admin clears it.

Area Paths
Workflows workflows/channelAssistant.ts, channelScheduledTask.ts, channelReactive.ts, taskChild.ts
Activities activities/channelAssistant.ts, channelReactive.ts, channelTask.ts, channelTaskPlan.ts, channelOpenItems.ts, consolidateChannelMemory.ts, passiveIngestChannelMemory.ts
Worker libs lib/channelMemory.ts, channelPersona.ts, channelTurnPrompts.ts, embeddingClustering.ts, slackNotify.ts
Steering workflows/runnable.ts (steer handler), activities/runAgentNode.ts (prependSteering), shared/workflow/interpreter.ts (drainSteering)
Gateway routes/slack.ts + routes/slack/events.ts (events), lib/slack/ (thread-reply steering, channel provisioning), routes/slackChannels.ts, plugins/temporal.ts (startChannelAssistant)
Web app/admin/slack-channels/, app/teams/[id]/page.tsx (persona card), hooks/useSlackChannels.ts
Shared lib/channelTask.ts (channelTaskWorkflowId, CHANNEL_TASK_STEER_SIGNAL), lib/syncBuiltins.ts (channelAssistant, syncChannelTaskTemplate)

© 2026 Jorge Barnaby

Source-available under FSL-1.1-MIT

Source on GitHub

Built with Astro and Starlight