slack
── WHY THIS MODULE EXISTS ──────────────────────────────────────────────────This code used to live vendored byte-for-byte in three modules (`pacha`,
`pacha-api`, `pacha-site`), 363 lines each, and the only thing keeping the
copies equal was an md5 recorded in `.engine-parity`. On 2026-09-04 that guard
found a real divergence that had been there for months: `pacha` carried one
extra line nobody had mirrored. Twelve months of a convention held up by a note.
── WHY IT IS A CLIENT AND NOT AN ORCHESTRATOR ──────────────────────────────
The vendored version exposed one `run(title, meta, items, …, body)` that took
the whole pipeline as an async CALLBACK. That shape cannot cross a module
boundary: a Dagger function is a GraphQL call, and a TypeScript closure is not
serialisable. So the split is:
· here everything that is genuinely the same everywhere — Block Kit
rendering, the trend read, the breakdown, the HTTP calls.
· in each repo a ~40-line loop holding `items`, `startedAt`, `metrics` and
`ts`, which is where the orchestration actually differs.
Duplication therefore drops from 363 lines to ~40, not to zero. Claiming zero
would be claiming something this interface cannot deliver.
The one loop that turned out to be the same everywhere — a watcher that keeps
one card per multi-job workflow run — lives in the `runcard` module, which
depends on this one. This module stays a client.
── WHAT IS DELIBERATELY NOT PORTED ─────────────────────────────────────────
`approveProd` — the production approval gate that used to poll Slack from
inside Dagger. It was retired on 2026-08-22 (ADR-0003 §2.3) and replaced by a
GitHub deployment protection rule served by the `deploy-gate` service, because
polling held a self-hosted runner busy for up to 30 minutes waiting for a
click. Verified on 2026-09-05: `ctx.gate` is called by NO pipeline in any of
the three repos, so it is dead code in all of them.
Porting it here would take that retired second path to production and make it
freshly callable by four repos at once — which is exactly the debt ADR-0003
§7.1 named and the 2026-08-22 migration closed. ONE PATH TO PRODUCTION.
── COMPLEX ARGUMENTS TRAVEL AS JSON STRINGS ────────────────────────────────
Dagger's TypeScript SDK exposes structural types poorly across a module
boundary, and a shape mismatch there fails at call time with an unreadable
error. Every non-scalar crosses as a JSON string, and the shapes are documented
on each function. The caller does `JSON.stringify`; this module parses and
validates.
Installation
dagger install github.com/wildbitca/daggerverse/slack@v0.2.0Entrypoint
Return Type
Slack Example
dagger -m github.com/wildbitca/daggerverse/slack@928b214a0dec70c7c68291365e26508b63fa8b92 call \
func (m *MyModule) Example() *dagger.Slack {
return dag.
Slack()
}@function
def example() -> dagger.Slack:
return (
dag.slack()
)@func()
example(): Slack {
return dag
.slack()
}Types
Slack 🔗
post() 🔗
Post or edit the progress card. Returns the message ts.
With an empty ts it posts (chat.postMessage); with one it edits in place
(chat.update). ONE message per run, edited — not a thread of new messages.
Best-effort by contract: a Slack outage logs and returns the ts it was
given, and NEVER brings the pipeline down. A notification channel that can
fail the build is a notification channel that gets removed.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| token | Secret ! | - | No description provided |
| channel | String ! | - | No description provided |
| body | String ! | - | JSON of the Slack message body — what |
| ts | String ! | "" | message to edit; empty posts a new one. |
Example
dagger -m github.com/wildbitca/daggerverse/slack@928b214a0dec70c7c68291365e26508b63fa8b92 call \
post --token env:MYSECRET --channel string --body string --ts stringfunc (m *MyModule) Example(ctx context.Context, token *dagger.Secret, channel string, body string, ts string) string {
return dag.
Slack().
Post(ctx, token, channel, body, ts)
}@function
async def example(token: dagger.Secret, channel: str, body: str, ts: str) -> str:
return await (
dag.slack()
.post(token, channel, body, ts)
)@func()
async example(token: Secret, channel: string, body: string, ts: string): Promise<string> {
return dag
.slack()
.post(token, channel, body, ts)
}render() 🔗
Render the progress card. Pure — no container, no network.
The returned JSON carries metadata.event_type + event_payload, which is
what readTrend reads on the NEXT run: Slack itself is the trend store, so
there is no database to run.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| title | String ! | - | No description provided |
| status | String ! | - | running | waiting | ok | fail |
| meta | String ! | - | JSON Meta |
| items | String ! | - | JSON Item[] |
| elapsedMs | Integer ! | - | milliseconds since the run started, measured by the caller |
| eventType | String ! | - | trend series key — MUST match /^[a-z0-9_]+$/ (Slack rejects the rest) |
| metrics | String ! | "" | JSON TestMetrics, or “” when the suite has not reported yet |
| trend | String ! | "" | JSON Trend from the previous run, or “” |
Example
dagger -m github.com/wildbitca/daggerverse/slack@928b214a0dec70c7c68291365e26508b63fa8b92 call \
render --title string --status string --meta string --items string --elapsed-ms integer --event-type string --metrics string --trend stringfunc (m *MyModule) Example(ctx context.Context, title string, status string, meta string, items string, elapsedMs int, eventType string, metrics string, trend string) string {
return dag.
Slack().
Render(ctx, title, status, meta, items, elapsedMs, eventType, metrics, trend)
}@function
async def example(title: str, status: str, meta: str, items: str, elapsedms: int, eventtype: str, metrics: str, trend: str) -> str:
return await (
dag.slack()
.render(title, status, meta, items, elapsedms, eventtype, metrics, trend)
)@func()
async example(title: string, status: string, meta: string, items: string, elapsedMs: number, eventType: string, metrics: string, trend: string): Promise<string> {
return dag
.slack()
.render(title, status, meta, items, elapsedMs, eventType, metrics, trend)
}readTrend() 🔗
Read the previous run’s metrics from Slack — the trend store is Slack itself.
Matches on event_type AND repo: event_type alone was not enough once
two lanes of the same repo shared a series, and the comparison silently
compared a five-emulator e2e against a Simulator build and printed a
perfectly plausible, false “↑12m vs previous”.
cacheBust MUST vary per run (use the run id). Without it Dagger serves the
previous exec from cache and every run reads the same stale trend — a network
read cached is a network read that did not happen (Gotcha 2).
Best-effort: returns “” on any failure. No trend is a missing line on a card; a hard failure here would be a pipeline down because a chat app was slow.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| token | Secret ! | - | No description provided |
| channel | String ! | - | No description provided |
| eventType | String ! | - | No description provided |
| repo | String ! | - | No description provided |
| cacheBust | String ! | - | No description provided |
Example
dagger -m github.com/wildbitca/daggerverse/slack@928b214a0dec70c7c68291365e26508b63fa8b92 call \
read-trend --token env:MYSECRET --channel string --event-type string --repo string --cache-bust stringfunc (m *MyModule) Example(ctx context.Context, token *dagger.Secret, channel string, eventType string, repo string, cacheBust string) string {
return dag.
Slack().
Readtrend(ctx, token, channel, eventType, repo, cacheBust)
}@function
async def example(token: dagger.Secret, channel: str, eventtype: str, repo: str, cachebust: str) -> str:
return await (
dag.slack()
.readtrend(token, channel, eventtype, repo, cachebust)
)@func()
async example(token: Secret, channel: string, eventType: string, repo: string, cacheBust: string): Promise<string> {
return dag
.slack()
.readTrend(token, channel, eventType, repo, cacheBust)
}findCard() 🔗
Find the ts of the card a given run posted, by its metadata. Returns “”
when there is none.
A card is identified by event_type + run_id + run_attempt, never by
run_id alone: a re-run keeps the run id, and matching on it alone threads
attempt 2’s detail under attempt 1’s card, which already says “FAILED”.
An empty runAttempt matches any attempt and takes the newest, which is what
a caller that does not know its attempt wants.
Only TOP-LEVEL messages are searched (conversations.history), because that
is where a card lives; replies are not cards. include_all_metadata=true is
not optional: without it Slack omits metadata and nothing ever matches.
⚠️ channel must be the channel ID (C…), not its name. chat.postMessage
accepts a name, conversations.history does not, and answers
channel_not_found, which this function reports as “no card”.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| token | Secret ! | - | No description provided |
| channel | String ! | - | No description provided |
| eventType | String ! | - | No description provided |
| runId | String ! | - | No description provided |
| cacheBust | String ! | - | MUST change on every call that must see new messages. A lookup served from Dagger’s cache returns the ts it found last time — or the “” it found before the card existed. |
| runAttempt | String ! | "" | No description provided |
| limit | Integer ! | 200 | how many recent top-level messages to scan (Slack caps at 999). |
Example
dagger -m github.com/wildbitca/daggerverse/slack@928b214a0dec70c7c68291365e26508b63fa8b92 call \
find-card --token env:MYSECRET --channel string --event-type string --run-id string --cache-bust string --run-attempt string --limit integerfunc (m *MyModule) Example(ctx context.Context, token *dagger.Secret, channel string, eventType string, runId string, cacheBust string, runAttempt string, limit int) string {
return dag.
Slack().
Findcard(ctx, token, channel, eventType, runId, cacheBust, runAttempt, limit)
}@function
async def example(token: dagger.Secret, channel: str, eventtype: str, runid: str, cachebust: str, runattempt: str, limit: int) -> str:
return await (
dag.slack()
.findcard(token, channel, eventtype, runid, cachebust, runattempt, limit)
)@func()
async example(token: Secret, channel: string, eventType: string, runId: string, cacheBust: string, runAttempt: string, limit: number): Promise<string> {
return dag
.slack()
.findCard(token, channel, eventType, runId, cacheBust, runAttempt, limit)
}breakdown() 🔗
The closing detail, in the card’s THREAD: duration per step, the test table and the trend. Posted on ok AND on fail — a run that failed is the one whose detail somebody actually needs.
In the thread and not as a second channel message: the card is already where this run is being watched, and a loose message forces pairing them by eye.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| token | Secret ! | - | No description provided |
| channel | String ! | - | No description provided |
| threadTs | String ! | - | No description provided |
| status | String ! | - | No description provided |
| items | String ! | - | No description provided |
| elapsedMs | Integer ! | - | No description provided |
| metrics | String ! | "" | No description provided |
| trend | String ! | "" | No description provided |
Example
dagger -m github.com/wildbitca/daggerverse/slack@928b214a0dec70c7c68291365e26508b63fa8b92 call \
breakdown --token env:MYSECRET --channel string --thread-ts string --status string --items string --elapsed-ms integer --metrics string --trend stringfunc (m *MyModule) Example(ctx context.Context, token *dagger.Secret, channel string, threadTs string, status string, items string, elapsedMs int, metrics string, trend string) string {
return dag.
Slack().
Breakdown(ctx, token, channel, threadTs, status, items, elapsedMs, metrics, trend)
}@function
async def example(token: dagger.Secret, channel: str, threadts: str, status: str, items: str, elapsedms: int, metrics: str, trend: str) -> str:
return await (
dag.slack()
.breakdown(token, channel, threadts, status, items, elapsedms, metrics, trend)
)@func()
async example(token: Secret, channel: string, threadTs: string, status: string, items: string, elapsedMs: number, metrics: string, trend: string): Promise<string> {
return dag
.slack()
.breakdown(token, channel, threadTs, status, items, elapsedMs, metrics, trend)
}threadReply() 🔗
Reply in the card’s thread. The per-repo detail (which flows are red, which .dart file broke) is posted with this — the engine knows nothing about flows.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| token | Secret ! | - | No description provided |
| channel | String ! | - | No description provided |
| threadTs | String ! | - | No description provided |
| text | String ! | - | No description provided |
| blocks | String ! | "" | JSON of a Block Kit array, or “” for a plain-text reply. |
Example
dagger -m github.com/wildbitca/daggerverse/slack@928b214a0dec70c7c68291365e26508b63fa8b92 call \
thread-reply --token env:MYSECRET --channel string --thread-ts string --text string --blocks stringfunc (m *MyModule) Example(ctx context.Context, token *dagger.Secret, channel string, threadTs string, text string, blocks string) string {
return dag.
Slack().
Threadreply(ctx, token, channel, threadTs, text, blocks)
}@function
async def example(token: dagger.Secret, channel: str, threadts: str, text: str, blocks: str) -> str:
return await (
dag.slack()
.threadreply(token, channel, threadts, text, blocks)
)@func()
async example(token: Secret, channel: string, threadTs: string, text: string, blocks: string): Promise<string> {
return dag
.slack()
.threadReply(token, channel, threadTs, text, blocks)
}failureDetail() 🔗
The error block a failed run posts in its thread before the breakdown.
Truncates from the END: the last 2600 characters of a stack trace are the ones that say what broke; the first 2600 are the ones that say the build started.
Return Type
String !Arguments
| Name | Type | Default Value | Description |
|---|---|---|---|
| token | Secret ! | - | No description provided |
| channel | String ! | - | No description provided |
| threadTs | String ! | - | No description provided |
| stage | String ! | - | No description provided |
| detail | String ! | - | No description provided |
Example
dagger -m github.com/wildbitca/daggerverse/slack@928b214a0dec70c7c68291365e26508b63fa8b92 call \
failure-detail --token env:MYSECRET --channel string --thread-ts string --stage string --detail stringfunc (m *MyModule) Example(ctx context.Context, token *dagger.Secret, channel string, threadTs string, stage string, detail string) string {
return dag.
Slack().
Failuredetail(ctx, token, channel, threadTs, stage, detail)
}@function
async def example(token: dagger.Secret, channel: str, threadts: str, stage: str, detail: str) -> str:
return await (
dag.slack()
.failuredetail(token, channel, threadts, stage, detail)
)@func()
async example(token: Secret, channel: string, threadTs: string, stage: string, detail: string): Promise<string> {
return dag
.slack()
.failureDetail(token, channel, threadTs, stage, detail)
}