The slack CLI is on $PATH and just works — every call runs through the Kortix connector gateway, which resolves the Slack bot token server-side. There is no token in your sandbox and nothing to configure: don't look for $SLACK_BOT_TOKEN, don't reach for an MCP/HTTP workaround, just run the commands below. They are the full, supported surface (posting, file upload, history, reactions, search, edit/delete — all of it). Two patterns matter most:
slack step "..."— narrate progress. Updates the live plan block in the Slack thread as you go.slack send "..."— finalize the turn with your answer. This closes the plan block and posts the reply.
Everything else (slack history, slack react, slack send --file, slack search, …) is for when the task explicitly asks for it.
If the project isn't wired to Slack yet (or the user asks to connect/set up Slack), run:
kortix channels connect
On Kortix Cloud this prints a one-click "Add to Slack" install link. Your
entire job is: surface that URL to the user and tell them to open it, pick
their workspace, and click Allow. That's the whole setup — the platform's
shared Slack app handles the webhook, tokens, and connector materialization
automatically. Verify afterwards with kortix channels status (or run
kortix channels connect --wait to block until the install lands).
Do NOT:
- walk the user through creating a Slack app at api.slack.com,
- print or paste the app manifest,
- ask for a bot token or signing secret,
- mint a secret-intake link for
SLACK_BOT_TOKEN/SLACK_SIGNING_SECRET, - try
kortix connectors add slack(the slug is reserved; it will be rejected).
The manual path exists ONLY for self-hosted servers without the shared Slack
app — and kortix channels connect detects that case itself and prints the
manual instructions (kortix channels manifest + --manual). Trust its
output; don't pre-empt it.
slack step "<title>" — emit a checkpoint
Call this before each major step of your work. Keep titles short, human, and present-tense. A few per task — not one per shell command.
slack step "Reading the incident logs"
slack step "Cross-referencing with the deploy timeline"
slack step "Drafting the post-mortem"
--detail "<subtitle>" — short context line under the title
Use --detail to add a one-line subtitle that explains what specifically you're doing in this step. Shown while the step is in_progress.
slack step "Reading the incident logs" --detail "Last 24h, severity >= warn"
--output "<result>" — concrete result attached to the PREVIOUS step
When you start a new step and the previous one produced a concrete result, surface it with --output. It attaches to the step that's closing — the one transitioning to complete.
slack step "Cross-referencing with the deploy timeline" \
--output "Found 47 ERROR lines clustered around 14:32 UTC"
slack step "Drafting the post-mortem" \
--output "3 candidate deploys in the window; api@a3f1 looks suspicious"
Inline links inside --detail / --output
Use Slack mrkdwn link syntax <https://… |label> (NOT Markdown [label](url)). Slack server-parses these into proper rich-text link elements rendered inside the task card:
slack step "Reading the incident logs" \
--detail "Pulling from <https://datadog.example.com/dash/api-errors|Datadog API errors>"
slack step "Cross-referencing the timeline" \
--output "Tied to <https://github.com/acme/api/commit/a3f1|api@a3f1> — auth middleware"
--source URL|TITLE — citation footer (repeatable)
Attach structured citations to the closing task. Slack renders them as a sources strip under that task's card. Pass multiple --source lines separated by newlines (use shell heredoc or repeat the flag in a wrapper).
slack step "Drafting the post-mortem" \
--output "3 candidate deploys" \
--source $'https://github.com/acme/api/commit/a3f1|api@a3f1
https://datadog.example.com/dash/api-errors|Datadog dashboard'
Up to 8 sources per task, titles auto-trim at 80 chars.
So the natural pattern, end-to-end, looks like:
slack step "Reading the incident logs" --detail "Last 24h, severity >= warn"
# ... do the work ...
slack step "Cross-referencing the deploy timeline" \
--output "47 ERROR lines around 14:32 UTC" \
--detail "Walking back from the first error"
# ... do the work ...
slack step "Drafting the post-mortem" \
--output "Pinned to api@a3f1 — auth middleware change" \
--detail "Writing root cause + remediation"
# ... do the work ...
slack send "It was api@a3f1 — the new auth middleware drops the trace header on retries. Reverting now."
Rules
- Use
slack stepto mark phase transitions, not every shell call. ~3–6 per turn is right for most tasks; one perbashis noise. - Set
--detailand--outputonce per step. Re-sending them for the same step appends rather than replaces — surprising and ugly. - Keep them short.
--detailand--outputget truncated at 500 chars upstream; aim for one tight sentence. - Don't
slack stepafter you've calledslack send. The plan is closed. A later step exits non-zero withreason: turn_finalized. - A step that did not reach the thread FAILS — it never answers
ok: true.slack stepexits 1 with{"ok": false, "code": "STEP_NOT_RELAYED", "reason": "<why>", "error": "…<what to do>"}. Readreason, then act:no_open_turn— this run was not started from Slack (a web prompt on a Slack-born session), or the turn was already closed. Keep working; if the user is waiting in a thread, post there withslack send --channel <id> --thread <ts>.turn_finalized— you already answered this turn. Oneslack sendper turn.relay_request_failed— the Kortix API refused the relay (auth, 5xx, timeout). That is a platform problem, not a missing turn: retry once, then report it in the thread with--channel/--thread.stream_open_failed/post_failed— Slack rejected the message. Continue; deliver the answer withslack sendat the end. Never assume a step was seen when the command failed.
Slack enforces a hard ~5-minute idle timeout on a streaming turn. The clock is idle time since the last stream update, not total turn length — and it is not bypassable from Slack's side. Every slack step you emit is a stream update that resets the timer to zero. If more than ~5 minutes pass with no update, Slack kills the turn and paints a red error in the thread — even though your agent is alive and working fine. The work usually still completes in the background; the user just sees a scary "failed" state. (Same root cause as a finished plan block getting stuck on "in_progress" — the stream got severed before the final slack send.)
The platform runs a safety-net heartbeat (a watchdog touches any quiet-but-alive stream every ~3 min so Slack doesn't auto-fail it). Treat that as a backstop, not an excuse to go silent: it only paints a generic "Working on it…" tick, it doesn't help if the watchdog is down/lagging, and a wall of nothing for minutes is bad UX. You keeping the stream warm with real checkpoints is still the primary fix.
So: a turn doesn't fail because the work is slow. It fails when it goes quiet. Don't go quiet.
Rules to never trip it
- Never go >~4 minutes without a
slack step. Treat 4 min as your budget; post before you spend it. A step is cheap — emitting one extra is free, tripping the timeout is not. - Before any long-running operation, post a step first. Anything that can take minutes —
git clone,pnpm install, a test suite, a build,pnpm preview, deep web research, a big LLM call, atask/subagent — gets aslack stepimmediately before you start it, so the timer is fresh going in. - Break long single operations into narrated phases. Don't do "clone + install + typecheck + test" as one silent 12-minute block. Step between each:
slack step "Installing deps"→slack step "Running typecheck"→slack step "Running tests". Each one resets the clock and reads better. - For a genuinely long single command (one >4-min step with nothing to narrate), stream a heartbeat from a background loop so the timer keeps resetting while it runs:
Kill the heartbeat the instant the command returns, and never leave a heartbeat running after# keep the Slack stream warm during a long blocking command ( while sleep 200; do slack step "Still working… (running tests)"; done ) & HB=$! pnpm test # the long thing kill "$HB" 2>/dev/null # stop the heartbeat as soon as it returnsslack send(steps after send drop silently, and a stray background loop is a leak — see the "stop long-running processes before you finish" rule). - It's idle-time, not call-count. Ten steps in one minute then six minutes of silence still trips it. Spacing is what matters, not volume — but err toward more frequent updates on long tasks; it's the single biggest reliability win.
For a one-liner, post directly:
slack send "Reverted api@a3f1. Errors are back to baseline — auth header is now preserved on retry."
This finalizes the live stream and renders the message below the plan block.
slack send --blocks-file <path> — Block Kit answer (preferred for structure)
When the response has real structure — sections, headers, lists, links, citations — ship it as Block Kit. Slack accepts a closing blocks chunk on chat.stopStream, so the rich layout renders inline below the plan block. Always pair --blocks-file (or --blocks) with --text for the notification fallback.
Write the JSON to a temp file, then send:
cat > /tmp/answer.json <<'EOF'
[
{ "type": "header", "text": { "type": "plain_text", "text": "Incident summary" } },
{ "type": "section", "text": { "type": "mrkdwn", "text": "*Root cause:* <https://github.com/acme/api/commit/a3f1|api@a3f1> — the new auth middleware drops the trace header on retries." } },
{ "type": "divider" },
{ "type": "section", "fields": [
{ "type": "mrkdwn", "text": "*Impact*\n14:32–14:51 UTC\n~3% of API requests" },
{ "type": "mrkdwn", "text": "*Action*\nReverted, deploying now" }
] },
{ "type": "context", "elements": [
{ "type": "mrkdwn", "text": "Sources: <https://datadog.example.com/dash/api-errors|Datadog> · <https://github.com/acme/api/pull/8421|Revert PR>" }
] }
]
EOF
slack send --text "Reverted api@a3f1 — root cause was the auth middleware" --blocks-file /tmp/answer.json
Use Block Kit when ANY of these apply:
- The answer has 2+ distinct sections (root cause + impact + action, or summary + details + sources).
- You're presenting comparisons, tables, or lists of items.
- The answer should cite multiple sources prominently.
- The response benefits from a clear title (use a
headerblock).
Use plain slack send "..." when the answer is short prose with no structure.
Block Kit cheat sheet
Common block types the agent uses most:
| Block | Use for |
|---|---|
header (plain_text) |
Title of the answer |
section (mrkdwn) |
Most prose, including `<url |
section with fields |
2-column key/value layout (max 10 fields) |
divider |
Visual break between sections |
context (mrkdwn) |
Small footer text, sources, timestamps |
image (image_url + alt_text) |
Charts, screenshots — needs a public URL |
actions (buttons / select menu) |
Inline interactivity for follow-ups |
carousel (of card elements) |
Side-scrollable gallery — see below |
Carousel of cards — for presenting a list of choices/items
When the answer is a list of things the user might pick between or browse (deploy candidates, repo search hits, scheduled meetings, design variants), a carousel of cards reads way better than a markdown list. Each card has an icon, a hero image, title/subtitle, body, and one or more buttons.
Important constraints (Slack rules, not ours):
- Cards live ONLY inside a
carouselblock. - Cards support
body(mrkdwn) andactions(buttons / select menus) — they do NOT supportinput/radio_buttons/checkboxes. If you need form inputs, use thequestiontool instead. - Each card's button click fires a
block_actionsinteraction; the platform routes it back as a follow-up Slack message (Picked: <button label>) into the same thread, the agent's next turn starts from that. - 2–10 cards per carousel.
Example — presenting 3 deploy candidates:
[
{ "type": "section", "text": { "type": "mrkdwn", "text": "*Three deploy candidates* — pick one to ship:" } },
{
"type": "carousel",
"elements": [
{
"type": "card",
"block_id": "deploy_a3f1",
"icon": { "type": "image", "image_url": "https://github.com/acme.png?size=36", "alt_text": "acme" },
"title": { "type": "mrkdwn", "text": "*api@a3f1* — Production-ready" },
"subtitle": { "type": "mrkdwn", "text": "2 commits ahead of main · ✓ tests pass" },
"hero_image": { "type": "image", "image_url": "https://opengraph.githubassets.com/1/acme/api", "alt_text": "diff" },
"body": { "type": "mrkdwn", "text": "Auth middleware retry fix + new metric. Safe to ship — small surface area, full coverage." },
"actions": [
{ "type": "button", "style": "primary", "text": { "type": "plain_text", "text": "Deploy this" }, "action_id": "deploy_a3f1", "value": "a3f1" },
{ "type": "button", "text": { "type": "plain_text", "text": "View diff" }, "url": "https://github.com/acme/api/compare/main...a3f1" }
]
},
{ "type": "card", "block_id": "deploy_b27e", "title": { "type": "mrkdwn", "text": "*api@b27e* — Needs review" }, "subtitle": { "type": "mrkdwn", "text": "12 commits ahead · ⚠️ 1 failing flake" }, "body": { "type": "mrkdwn", "text": "Bigger release: rate-limiter rework + Stripe webhook fix. Flake is in webhook tests." }, "actions": [ { "type": "button", "text": { "type": "plain_text", "text": "Deploy this" }, "action_id": "deploy_b27e", "value": "b27e" }, { "type": "button", "text": { "type": "plain_text", "text": "Hold" }, "style": "danger", "action_id": "hold_b27e", "value": "b27e" } ] },
{ "type": "card", "block_id": "deploy_c901", "title": { "type": "mrkdwn", "text": "*api@c901* — Hotfix only" }, "subtitle": { "type": "mrkdwn", "text": "1 commit · auth header preserve" }, "body": { "type": "mrkdwn", "text": "Minimal fix for the 14:32 incident. Lowest risk." }, "actions": [ { "type": "button", "style": "primary", "text": { "type": "plain_text", "text": "Deploy this" }, "action_id": "deploy_c901", "value": "c901" } ] }
]
}
]
Ship it the same way as any Block Kit answer:
slack send --text "Pick a deploy candidate" --blocks-file /tmp/candidates.json
When to reach for carousel:
- 2–6 items the user is choosing between, each with non-trivial context (subtitle, body, hero image).
- The agent expects the user's button click to become the next prompt, not a fill-in form.
When NOT to:
- For mid-turn structured input (use
questioninstead — carousel can't host inputs). - For 1 item (just a section) or 7+ items (split, paginate, or summarize).
- For a quick yes/no (use
questionwith single-select).
Full Block Kit reference: https://docs.slack.dev/reference/block-kit/blocks/
Tone (applies to either mode)
Reply like a colleague messaging on Slack:
- No preamble. Don't open with "Sure!" / "I've taken a look and…". Get to the answer.
- Slack mrkdwn.
*bold*(single asterisks),_italic_,`code`,```code blocks```. Markdown-style**bold**renders as literal asterisks — don't use it. - Short. A few sentences > a wall of text. Use bullet lists for ≥3 items.
- Link with
<url|text>. Slack's syntax, not Markdown[label](url). - No XML / no "Here's a summary:" headers. This is a chat message, not a report.
One slack send per turn
Each turn finalizes exactly one stream. Don't call slack send twice — the second call exits 1 with reason: turn_finalized because the stream is already closed. If you need to deliver multiple things, fold them into one Block Kit message or use slack send --channel ... for sibling posts.
When slack send fails
A slack send with no --channel is the turn's answer. When it cannot be delivered into a Slack turn it exits 1 with {"ok": false, "code": "ANSWER_NOT_RELAYED", "reason": "<why>", "error": "…<what to do>"} — it never prints ok: true for an undelivered answer. no_open_turn means this run was not started from Slack, or the turn was closed before you answered; if the user is waiting in a thread, deliver the same answer with slack send --channel <id> --thread <ts> (the channel and thread are in the prompt header and in $SLACK_CHANNEL_ID / $SLACK_THREAD_TS). relay_request_failed is a Kortix API failure, not a missing turn: retry once, then post with --channel/--thread.
Rule: if your reply contains a question, call the question tool. Never put questions inside slack send.
slack send finalizes the turn and closes the live stream — once it fires, the user can only reply with a free-text message. If you posted multi-choice questions via slack send, the answers come back as unstructured prose and you have to re-parse them. Don't.
opencode ships a native question tool. Call it the same way you would in any other host (dashboard, TUI). When the turn is Slack-triggered, the sandbox automatically catches the question.asked event and renders a Block Kit form (radio buttons / checkboxes / a free-text box) in the same thread. The user submits → opencode resumes your tool call with their answers. Zero Slack-specific glue from your side.
| When you want to… | Use |
|---|---|
| Ask a question or set of questions | question tool |
| Deliver the final answer / summary | slack send |
| Show progress along the way | slack step |
| Post a separate message to another channel | slack send --channel ... |
Calling the question tool
Per opencode's schema (Array<QuestionInfo>):
{
"questions": [
{
"question": "Which environment should I deploy to?",
"header": "Environment", // short label (max 30 chars)
"options": [
{ "value": "prod", "label": "Production" },
{ "value": "staging", "label": "Staging" },
{ "value": "dev", "label": "Dev (sandbox)" }
],
"multiple": false, // false = radio, true = checkboxes
"custom": true // true = also show a free-text box
}
]
}
Multiple questions in one call:
{
"questions": [
{
"question": "Priority for next sprint",
"header": "Priority",
"options": [
{ "value": "auth", "label": "Finish the auth migration" },
{ "value": "billing", "label": "Ship metered billing v2" },
{ "value": "ingest", "label": "Rebuild the ingest pipeline" }
],
"multiple": false
},
{
"question": "What risks should I flag?",
"header": "Risks",
"options": [
{ "value": "rollback", "label": "Rollback complexity" },
{ "value": "perf", "label": "Performance regressions" },
{ "value": "data", "label": "Data migrations" }
],
"multiple": true
},
{
"question": "Any constraints I should know about?",
"header": "Constraints",
"options": [],
"custom": true
}
]
}
Reading the answer
The question tool's return value is answers: string[][] — one array per question, in the same order you sent them. Each inner array contains every value the user picked, plus any free-text they typed into the custom field (concatenated at the end).
// for the 3-question example above
[
["auth"], // single-select
["rollback", "data"], // multi-select
["Vendor X freeze ends Tuesday."] // custom text only
]
Rules
- Use the
questiontool, not chat prose. A free-text reply loses structure. - Keep it focused. 1–3 questions per call. If you need more, split into multiple
questioncalls across the turn (each is its own pause). - Form expires after 15 minutes. If the user doesn't click Submit, the form resolves with empty arrays — opencode treats that as a reject; the tool returns and you can adapt.
- The Stop button still works. A user who clicks 🛑 Stop aborts the turn; the form is closed and the tool returns empty.
- Skip trivial yes/no when context implies the answer. Use judgment — the form is for real decisions, not "are you sure?" rituals.
File upload is fully supported — slack send --file is the way. When the work produces an artifact (a PDF, a CSV, a report, a diff, a screenshot), upload it with --file instead of pasting the contents or improvising. Do not conclude "uploads aren't supported", and do not reach for the connector MCP, a manual files.getUploadURLExternal call, or an HTTP-host-a-link hack — --file already does the multi-step upload for you, server-side. It requires a --channel and (typically) a --thread so it lands under the answer:
slack send \
--channel "$SLACK_CHANNEL_ID" \
--thread "$SLACK_THREAD_TS" \
--file /workspace/output/report.md \
--text "Full report ↓"
$SLACK_CHANNEL_ID and $SLACK_THREAD_TS are pre-set on Slack-triggered turns. Use them.
Note: slack send --file ... posts a separate message — it does NOT count as the turn's finalizing answer. Combine it with a regular slack send "..." to also close the stream:
slack send --channel "$SLACK_CHANNEL_ID" --thread "$SLACK_THREAD_TS" \
--file /workspace/output/report.md --text "Full report attached."
slack send "Pulled 12,847 sign-ups, grouped by source. CSV above."
Read prior thread context
slack history --channel "$SLACK_CHANNEL_ID" --thread "$SLACK_THREAD_TS"
slack thread --channel "$SLACK_CHANNEL_ID" --ts "$SLACK_THREAD_TS"
React to a message
slack react --channel "$SLACK_CHANNEL_ID" --ts "$SLACK_TRIGGER_TS" --emoji "white_check_mark"
Post to a different channel (announcements, cross-posts)
slack send --channel "C0123ABCD" --text "Heads up: rolled api@a3f1 forward."
Edit / delete a message you posted earlier
slack edit --channel "$SLACK_CHANNEL_ID" --ts "<msg_ts>" --text "Updated answer."
slack delete --channel "$SLACK_CHANNEL_ID" --ts "<msg_ts>"
Search the workspace
slack search --query "deploy api@"
Look up users / channels
slack users
slack user --user "U0123ABCD"
slack channels
slack channel-info --channel "C0123ABCD"
slack me
Download a file shared in the thread
slack file-info --file "F0123ABCD"
slack download --url "<file.url_private>" --out /workspace/incoming/x.png
Full help: slack help.