Your team's daily standup, written for you.
Inky is a Discord bot that reads an organization's GitHub activity each day and writes the standup automatically β per person and project-wide β with zero human input. No more "what did you do yesterday?" prompts: the information already lives in your commits, PRs, issues, and reviews. Inky reads it and writes the update.
Later it grows into a status tracker that reports where the project stands versus its plan, by reconciling activity against a task tracker.
Live β the MVP (Phases 0β5) is complete and running in production (self-hosted on a worker). See the project plan for the spec + decisions, and roadmap & Phase 6 for what's next.
| Phase | Scope | State |
|---|---|---|
| 0 | Scaffold: TS project, config schema, core types | β |
| 1 | collect() β GitHub API fetch + identity aliasing |
β |
| 2 | normalize() + render() β mechanical digest, LOC filtering, Discord delivery |
β |
| 3 | summarize() β AI-written standup (BYO key; Anthropic/Groq/OpenAI) |
β |
| 4 | Trigger + delivery β scheduled worker (serve) + /standup slash command |
β |
| 5 | reconcile() β status vs roadmap (paid hook) |
β GitHub milestones; Linear/Notion later |
| 6 | Hosted multi-tenant tier + dashboard (paid) | β |
A host-agnostic core pipeline; trigger and delivery are thin, swappable adapters:
trigger (cron β slash command)
β collect() GitHub API β raw events per author
β normalize() β unified Activity model
β [reconcile()] task tracker (Phase 5)
β summarize() β LLM β standup
β render() β Discord embed/markdown
delivery (webhook β bot post)
corepack enable # provides the pinned pnpm version
pnpm install
cp .env.example .env # add GITHUB_TOKEN (see token guide below)
cp inky.config.example.json inky.config.json # set your org/repos
pnpm --silent collect # fetch + print org activity as JSON
pnpm --silent standup --dry-run --days 1 # build a standup and print it (no Discord)
pnpm --silent serve --once --dry-run # run one worker cycle and print itTo actually post, set DISCORD_WEBHOOK_URL (in .env) and drop --dry-run.
Need a GitHub token? See docs/github-token-setup.md
for a secure, least-privilege (read-only) setup.
Use
pnpm --silentso only the JSON reaches stdout (without it, pnpm prints a script banner). The installedinkybinary needs no such flag.
Run inky help for the full reference. There are five commands:
| Command | What it does |
|---|---|
inky collect |
Fetch + normalize org activity, print as JSON (debugging). |
inky standup |
Build the standup once and post it (or print with --dry-run). |
inky ask "<question>" |
Answer a question about the activity, grounded in the digest (or print with --dry-run). |
inky serve |
Run forever: scheduled posts + the /standup & /ask bot. |
inky register-commands |
Register the /standup & /ask slash commands (run once). |
- Window (default = config
windowHours, ending now):--days NΒ·--hours NΒ·--since <ISO>Β·--until <ISO>. Pair--since/--untilfor an exact past window, e.g.--since 2026-06-01 --until 2026-06-02. - Report:
--stats/--no-statsΒ·--stats-per-personΒ·--roadmap/--no-roadmapΒ·--format prose|bulletsΒ·--mechanical(skip the AI). - Other:
--config <path>Β·--provider <p>Β·--model <id>Β·--dry-runΒ·--once(serve: one cycle then exit) Β·--no-watch(serve: don't hot-reload the config file).
inky standup --dry-run # preview today (nothing posted)
inky standup --days 1 # post a daily standup
inky standup --days 7 --stats # weekly, with the team stats panel
inky standup --since 2026-06-01 --until 2026-06-02 # replay an exact past window
inky ask "what shipped this week?" --days 7 --dry-run # grounded answer, printed
inky serve # run on a schedule, forever
inky serve --once --dry-run # test one scheduled cycle, printed(In dev, swap inky for pnpm --silent dev β e.g. pnpm --silent dev standup --dry-run.)
-
inky.config.jsonβ non-secret config: org, repos, window, identity aliases, Discord target, LLM provider/model. Copy frominky.config.example.json. -
Which repos β
repos: []scans every non-archived repo in the org; or list specific ones (["api", "web"]). Withrepos: [],staleDaysskips repos with no recent push so long-dead repos aren't queried (the run logs which):"auto"(recommended) β skips repos with no push since that run's window started, so the daily skips >24h-quiet repos and the weekly >7d-quiet, each correct by construction. No number to tune.- a number
Nβ fixed: skip repos with no push in N days (must be β₯ your longest scheduled window). 0β scan everything.
Based on last push, so a repo with only issue/review activity in the window is skipped too.
-
.envβ secrets only (GITHUB_TOKEN, an LLM key,DISCORD_WEBHOOK_URL). Never committed. -
GitHub token β a read-only fine-grained PAT scoped to your org + the repos you want, with permissions Contents Β· Metadata Β· Pull requests Β· Issues (all Read). It can't push, change settings, or touch other orgs. Full walkthrough β incl. the classic-token fallback and where to store it when you deploy β in
docs/github-token-setup.md. -
GitHub App (optional upgrade) β instead of a PAT, authenticate as a GitHub App installation: no token expiry, higher rate limits, clean revoke (uninstall). Same read-only access. Set
github.appIdin config (orGITHUB_APP_ID) + the private key in env (GITHUB_APP_PRIVATE_KEY_PATHorGITHUB_APP_PRIVATE_KEY); the App wins if both are set. Walkthrough:docs/github-app-setup.md.
The summary writer is provider-agnostic β one swappable call seam. Pick a
provider in config and set the matching key in .env; only one key is needed,
and with none, Inky falls back to the deterministic mechanical render.
provider |
Key (env) | Default model | Notes |
|---|---|---|---|
anthropic (default) |
ANTHROPIC_API_KEY |
claude-sonnet-4-6 |
Best grounding (faithful aggregates) + richest standup. Drop to claude-haiku-4-5 to cut cost. |
groq |
GROQ_API_KEY |
openai/gpt-oss-120b |
Fast, cheap, open-weight; grounds well. |
openai |
OPENAI_API_KEY |
gpt-4o-mini |
OpenAI, or any OpenAI-compatible endpoint via baseUrl (OpenRouter, local Ollama). |
model (config) or --model <id> overrides the default; baseUrl overrides the
endpoint (OpenAI-compatible providers only). The summary is constrained extraction
over a pre-built digest, so a small model holds up β defaults favor cost. Run
inky standup --mechanical to skip the AI entirely.
- Depth scales with the window. A daily standup is a terse pulse; weekly and monthly reviews get proportionally more detail (more sentences, more highlights).
- Stats lead the report. A team stats panel renders first (numbers before
prose).
stats: "auto"(default) shows it on weekly+ windows but not the daily pulse;"on"/"off"force it. Override per run with--stats/--no-stats. The PR-size distribution and the per-day commit activity each get a compact unicode sparkline (β ββββXSβXL;βββ ββ ββoldestβnewest day). LOC is labeled size, not score β seedocs/research/agentic-coding-metrics.md. - Week-over-week trends. With
trends: "auto"(default), the panel adds direction arrows vs the previous equal-length window β e.g.**12** PRs merged (β3),median PR cycle time: **18h** (β4h). Shown wherever the stats panel shows;--trends/--no-trends(ortrends: "off") override. It costs one extra activity fetch (the prior window), so it only runs when the panel does (weekly+). - Per-person stats (
statsPerPerson: true, default) add a stat line under each name, paired with the team panel (shown where it shows).--stats-per-personforces them on even on the daily; setfalseto keep the post team-level only. - Output style.
format: "bullets"(default) gives scannable bullet points per person;format: "prose"(or--format prose) gives a narrative paragraph. The project summary stays prose either way.
LOC is additions + deletions over real files only. A built-in noise filter excludes
lockfiles, generated code, vendored dependencies, build output, caches, and snapshots β the full
default list lives in packages/core/src/filter.ts.
- Configurable. Add repo-specific globs (picomatch syntax) via
extraNoisePatternsin your config to exclude anything else that inflates counts (e.g."**/*.snap","docs/reference/**"). - Bulk-import cap. A single commit whose real churn exceeds
maxCommitLines(default 300k) contributes 0 to LOC β path filtering can't catch a bulk commit of real-looking source (a vendored workspace, a reference-codebase import, an integration checkpoint), and one such commit can be 1M+ lines and swamp the whole team's totals. Tune viamaxCommitLinesin config. - Merge commits excluded. GitHub reports a merge's stats as the full merged-branch diff, so Inky forces merge-commit LOC to 0 β otherwise merges double-count the branch's real commits and credit someone else's branch to whoever merged it.
- Both rules only zero LOC; the commit still counts toward commit and active-day totals. Treat per-person LOC as a rough size signal, not a precise or comparative score.
Inky can tie the window's activity to your roadmap and add a π Status vs
plan block β what advanced, what's stalled, what's at risk. There are two
sources; pick one with source:
github-milestones (default, no extra setup β the milestone's open/closed
counts and due date give progress and "on track" for free):
roadmap-md β for teams that don't use Milestones: a checklist ROADMAP.md
in your repo, where ## headings are goals and - [ ] / - [x] tasks give
progress. Add (due: YYYY-MM-DD) to a heading to track a deadline:
"roadmap": {
"enabled": true,
"source": "roadmap-md",
"path": "ROADMAP.md", // file location (default)
"repo": "web", // repo holding it (default: the first configured repo)
"atRiskDays": 7
}## Q3 Launch (due: 2026-09-01)
- [x] Auth
- [ ] DashboardOff by default; enable in config or force per run with --roadmap / --no-roadmap.
Each tracked item shows progress, movement (advanced / stalled / completed / β¦),
and an ROADMAP.md simply see no block. (A static checklist carries no in-window signal, so
roadmap-md items show progress and at-risk, but not "advanced this period.") See
docs/planning/phase5-reconcile-design.md.
People commit under multiple identities (work + personal email, multiple machines).
The aliases map collapses them into one canonical GitHub login so per-person
activity merges correctly:
{ "aliases": { "canonical-login": ["alias-login", "personal@example.com"] } }Inky reads people's GitHub activity, so anyone can opt out. List canonical logins
in excludePeople and they're omitted entirely β never named, and their activity
isn't counted in the team stats:
{ "excludePeople": ["carol"] }A clean "don't report me." (Bots are already excluded by default via excludeBots.)
inky serve makes the standup post on its own β an in-process scheduler
(no external cron) runs the full pipeline on config.schedule and posts to
Discord. schedule.jobs is one or more scheduled posts, each with its own
cron and windowHours, so you can run a daily standup and a weekly one
from a single worker:
"schedule": {
"timezone": "America/New_York",
"jobs": [
{ "cron": "0 9 * * 1-5", "windowHours": 24, "label": "daily" }, // 9am weekdays, past day
{ "cron": "0 9 * * 1", "windowHours": 168, "label": "weekly" } // 9am Monday, past week
]
}windowHours per job defaults to the top-level windowHours if omitted. A
failed run is logged and the worker keeps going; run a single instance so the
channel isn't posted to twice. Deploy it to any always-on host (Render, Railway,
Fly.io, Docker) β see docs/deployment.md for step-by-step
guides (incl. a render.yaml) and the required secrets.
The same serve process can also answer a /standup command in Discord, so
anyone can pull a standup for any window on demand. It connects over Discord's
gateway (no public URL needed). Options let a caller override the configured
defaults per run:
/standup range:This week stats:On per_person:false format:prose
/standup range:This week private:true # only you see it
range (Today / This week / This month) or a custom days (1β90); stats
(On / Off / Auto), per_person, and format (Bullets / Prose) β all optional,
each falling back to inky.config.json. Add private:true to get the reply
ephemerally β visible only to you, so a manager can inspect the team's
activity without posting it to the channel. Enable it by setting DISCORD_BOT_TOKEN
discord.applicationId, runninginky register-commandsonce, theninky serve. Full walkthrough:docs/discord-bot-setup.md.
The same bot answers /ask β a grounded question about the team's recent
activity, instead of the full standup:
/ask question:what did the team ship this week? range:This week
/ask question:what's still in flight on the api? private:true # only you see it
Inky answers only from the window's verified activity (the same factual digest
the standup is built from): it cites real PRs, repos, and people, and if the answer
isn't in that activity it says so rather than guessing. Great for "what shipped?",
"what's in flight on X?", "who reviewed Y?". It can't yet answer questions that
need code diffs or per-PR timing (e.g. "why did #42 take so long?") β those come
with the agentic follow-up tier. /ask needs an LLM key (there's no mechanical
fallback). The CLI form is inky ask "<question>" [--days N] [--dry-run].
Inky is self-hosted, so by default the project has no idea anyone is running it β no install count, no usage signal. The optional telemetry block turns on an anonymous, aggregate ping so we can tell deployed instances apart from GitHub stars and see which features get used. It is off unless you turn it on, and when on, Inky prints a first-run line stating exactly that.
"telemetry": {
"enabled": true, // off by default β nothing is sent until you opt in
"endpoint": "https://your-ingest.example/t", // where events POST (run your own β see apps/ingest)
"instanceId": "auto" // a random local UUID; pin a value on ephemeral hosts
}What's sent is the entire payload β nothing else leaves your machine:
{ "event": "standup_run", "instanceId": "b3f1β¦", "version": "0.0.1", "ts": 1733400000,
"props": { "trigger": "scheduled", "windowHours": 24, "dryRun": false, "provider": "anthropic" } }Just an event name, a random install id, the Inky version, a timestamp, and a
few scalar counts/flags. The events are: instance_started, heartbeat
(daily liveness), standup_run, and ask_run (once /ask ships).
What is never sent: org or repo names, contributor logins or emails, commit
or PR content, your config values, or any key. The wire schema rejects non-scalar
props so a nested identity payload can't ride along β you can read the refusal
to over-collect in packages/core/src/telemetry.ts.
The sink is a ~tiny endpoint you can self-host (see
apps/ingest); design notes in
docs/planning/telemetry-design.md.
MIT
