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.
1. Data model
Section titled “1. Data model”| 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 |
2. Config cascade
Section titled “2. Config cascade”ConfigScope carries a CHANNEL value, giving the agent resolver five tiers:
WORKFLOW_TEMPLATE → CHANNEL → TEAM → ORGANIZATION → GLOBALThe 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.
3. Conversation
Section titled “3. Conversation”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.
4. Autonomous task execution
Section titled “4. Autonomous task execution”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}. AplanChannelTaskstep, deliberately biased against splitting, returns 1..N subtasks. A cohesive task runs a singlechannelAssistantnode; a splittable one runsrunChannelSubtasks, which does bounded-concurrency subtask fan-in plus synthesis inside one activity.finalizeChannelTaskRunreadsnodes.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
fanOutnode 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.
5. Proactivity
Section titled “5. Proactivity”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.
6. Memory
Section titled “6. Memory”Channel memory is ordinary MemoryItem rows scoped by channelId / teamId / orgId, retrieved
by the same pgvector search as everything else.
- Retrieval —
retrieveChannelMemoryreads the channel’s own memory and sibling channels on the same team, auto-injected as turn context. - Cross-channel reads are bounded twice.
searchTeamChannelMemoryandsearchOrgChannelMemoryfilter on the reading channel’steam_id/org_id, and joinslack_channelsto excludeis_private = truesources. A private channel’s memory is never a source for another channel, and no read crosses an org. isPrivatecomes from Slack. At provision time the gateway callsconversations.infofor the authoritativeis_private. When Slack cannot answer — no token, nogroups:readscope, a network failure — it falls back to the payload heuristic (channel_type: 'group'on the events path, aG-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) callsconversations.infofor every active channel and turnsisPrivateon 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’sSlackChannelMemberrows with Slack’sconversations.memberslist and stampsmembersSyncedAt; 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 thechannels:readandgroups:readbot scopes (slack-app-setup.md). - Passive ingestion — with
passiveIngestEnabled,passiveIngestChannelMemorysilently extracts up to five salient facts from human messages on each ambient fire, advancing apassiveIngestCursorand de-duplicating at 0.85 similarity. Accrues withcountRun: false. - Consolidation —
consolidateChannelMemoryclusters and merges related items on each ambient fire, honouring the per-channelconsolidationEnabledtoggle 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 anINJECTIONpattern 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).
7. Budgets
Section titled “7. Budgets”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.
8. Observability & admin
Section titled “8. Observability & admin”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.
9. Slack surface
Section titled “9. Slack surface”| 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.
10. Known limitations
Section titled “10. Known limitations”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.
finalizeChannelTaskRunspends 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.updateon 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.
scanChannelInputruns the samescanSkillContentthe implementer’s output scanner uses, but a warning only records achannel.suspicious_inputadvisory 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: abashcall is checked only againstSHELL_COMMANDpatterns, and the write-path scanners gate thewriteFiletool only. - The memory gate is pattern-based. It catches the phrasings the
INJECTIONpatterns 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. isPrivateis 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_HOURSif the sync keeps failing; someone added waits for the next sync. Access also follows the linkedUser.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.
11. Key files
Section titled “11. Key files”| 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