A trigger source lets a project pick up work on its own — the way Devin,
Cursor, Codex, Tembo and Charlie already do from GitHub issues/PR comments,
Slack and Linear — instead of always waiting for a human to press dispatch or
a routine's clock to tick. Implementation: internal/triggers (the polling
and Socket Mode engines) plus internal/api/triggers.go (the HTTP surface and
the one adapter that turns a matched event into a real task).
Lectern is typically reachable only on a private tailnet, so an inbound webhook from GitHub, Slack or Linear usually cannot reach it at all — there is nothing at the address those services would try to call. Every source here is designed around that constraint:
- GitHub is polled: lectern periodically asks GitHub what changed, using
the target's own
ghCLI login (the same onegh pr createalready uses for a human's Commit/PR button). - Linear is polled over its GraphQL API with an API key.
- Jira is polled over its REST API with an API token or personal access token.
- Slack uses Socket Mode — an outbound websocket lectern opens to Slack, which Slack then uses to carry events, slash commands and interactivity back over. No inbound HTTP endpoint is exposed for Slack either.
Nothing in this package listens for an inbound webhook. If you run lectern somewhere that genuinely is publicly reachable, none of this changes — polling and Socket Mode still work fine, they are just not the fastest possible path (GitHub events wait for the next poll instead of arriving instantly).
A trigger source is a door a stranger can knock on if it watches a public repo, channel or team. Four things make that safe by default:
- An author allowlist is mandatory. A source with an empty allowlist acts on nothing — every event is recorded as skipped with the reason "author not on this source's allowlist". You add logins/user ids/emails you trust explicitly; there is no "trust everyone" setting.
- The permission mode is never configurable per trigger. Every task a
trigger creates uses the project's own
default_permission_mode(falling back toacceptEdits) — the same gating a human's task already gets on that project. A trigger source's config cannot elevate this; the field does not exist to set. - Per-source rate limiting.
max_per_hourin a source's config (default 10) caps how many tasks one source can create in a rolling hour, checked before every task creation. A misconfigured label match or a chatty bot can only spend so much before it hits the wall. - Every event is recorded exactly once.
trigger_eventshas a unique index on(source_id, external_id)— a second delivery of the same GitHub webhook-equivalent, Slack envelope or Linear issue update is a no-op, not a second task. See "Recent events" below for how to read this ledger.
None of this is a substitute for scoping the credential itself: a GitHub
gh login, Slack bot token or Linear API key should have the narrowest access
that lets the trigger do its job.
Configuration lives in a project's Settings → that project's card → Triggers section (works down to 390px). Add a source, fill in its config and secrets, save, then use Test connection to check credentials before turning it on for real traffic.
Fields (config):
| Field | Default | Meaning |
|---|---|---|
repo |
— (required) | owner/repo |
label |
lectern |
issue label that creates a task |
queued_label |
<label>:queued |
label swapped in once filed, so the issue is never re-evaluated |
mention_handle |
@lectern |
text in a PR/issue comment that creates a follow-up task |
allowed_authors |
[] |
GitHub logins allowed to trigger work |
base_branch |
project default | branch new task worktrees are cut from |
agent / model |
project default | which agent runs the task |
max_per_hour |
10 | rate limit |
No secrets are stored for GitHub. Both polling and posting back run through
the project's own target and its gh CLI login — gh auth status on that
machine is what "Test connection" actually exercises reading repos/<repo>.
Behavior:
- An open issue carrying
labelfiles a task (title, body and the issue URL become the prompt), then the issue is relabelledlabel→queued_label— this is the durable "seen" marker on GitHub's own side, independent of lectern's event ledger. - A PR or issue comment mentioning
mention_handlefiles a follow-up task. - When a label-triggered task finishes with changes, lectern commits,
pushes and opens a pull request with
Closes #Nin the body, then comments the PR link on the issue. A task that finished with nothing to commit, or that failed/was cancelled, gets a comment explaining that instead — never a second silent attempt. - When a mention-triggered task finishes, lectern replies on the
originating comment's issue/PR with a result summary and diff stats (file
count,
+adds -dels) rather than opening a second pull request. - Rate-limit awareness: every
gh apicall is read with--include, and thex-ratelimit-remainingheader is checked before the (more numerous) comment poll; below 5 remaining, that poll is skipped for the tick rather than spending the budget a relabel or a human might need. - Cursors are timestamps (
since), not conditional-request ETags — see the comment ongithubCursorininternal/triggers/github.gofor why 304 handling throughgh apiwas not something this could verify against live GitHub without a real token in this environment, and a since-cursor is a fully documented, reliable substitute at small-team polling volumes.
Fields (config): channel (empty = any channel the bot is in),
allowed_users (Slack user ids, e.g. U0123ABCD), agent/model,
max_per_hour.
Secrets: app_token (xapp-..., app-level, used to open the Socket Mode
connection) and bot_token (xoxb-..., used to post messages).
Slack app setup, once, in api.slack.com:
- Create an app. Under Socket Mode, turn it on and generate an
app-level token with the
connections:writescope — that isapp_token. - Under OAuth & Permissions, add bot scopes
chat:write,app_mentions:readandcommands(if you want the slash command), install the app to your workspace, and copy the Bot User OAuth Token — that isbot_token. - Under Event Subscriptions, turn events on (Socket Mode does not need a
Request URL) and subscribe to the bot event
app_mention. - Optionally add a Slash Command named
/lectern— no Request URL needed either; Socket Mode carries it. - Invite the bot to whichever channel(s) you want it watching.
Behavior:
- A message that
@mentionsthe bot, or a/lectern <text>command, creates a task from the message/command text (the bot mention itself is stripped). - Lectern posts an immediate acknowledgement in the same channel/thread — "On it — created task #N…" — or, if the author was not allowlisted or the rate limit was hit, a short reply saying why nothing happened. Slack users expect some reply; a silent drop reads as broken.
- When the task finishes, lectern replies in the same thread with the result summary and diff stats.
- The connection is held open for as long as the source stays enabled, with
reconnect-with-backoff on any drop, and is torn down on shutdown
(
Manager.StopSlack, called fromApp.Close). Editing a source's config or secrets restarts its connection on the next tick rather than running on stale credentials until the socket happens to drop on its own.
If Socket Mode is not an option for your workspace, note that Slack Events also supports classic HTTP webhooks — but that needs a public URL, which is exactly the thing most lectern deployments do not have (see the tailnet note above). Socket Mode is deliberately the only implementation here; adding a webhook endpoint later is straightforward (an HTTP handler that verifies Slack's signing secret and forwards to the same intake path) if a deployment ever has a public origin and prefers it.
Fields (config): team_key (e.g. ENG), label (default lectern),
allowed_users (emails or display names of the issue's creator),
done_state_name (default Done), agent/model, max_per_hour.
Secrets: api_key (a personal or workspace API key from Linear's Settings →
API).
Behavior:
- An issue in
team_keycarryinglabel, created or updated after the source was enabled, files a task from its title, description and URL. - When the task finishes, lectern posts a comment on the issue with the result
summary and diff stats, then — unless the task failed or was cancelled —
moves the issue to the workflow state named
done_state_name. A missing state name is reported rather than guessed at; the issue is left where a human put it. - Cursor:
since(an updatedAt timestamp), same reasoning as GitHub's.
Works with Jira Cloud and Jira Server / Data Center.
Fields (config): base_url (e.g. https://yourteam.atlassian.net),
flavor (cloud or server; empty guesses from the host — *.atlassian.net
is Cloud), email (Cloud only: the account the token belongs to),
project_key (required), label (default lectern), allowed_users (the
reporter's email, account id, username or display name), done_transition
(default Done), agent/model, max_per_hour.
Secrets: token — an API token on Cloud (sent with the email as HTTP Basic,
REST v3), a personal access token on Server / Data Center (Bearer, REST v2).
Behavior:
- An issue in
project_keycarryinglabel, updated after the source was enabled, files a task from its key, summary, description and link. The search uses a relativeupdated >= "-Nm"window, because JQL reads absolute dates in the Jira user's own time zone; the event ledger drops anything seen twice. - Cloud often hides email addresses, so the reporter is matched against the allowlist by email, account id, username and display name in turn.
- When the task finishes, lectern comments the result summary and diff stats
(as ADF on Cloud), then — unless the task failed or was cancelled — applies
the transition whose name or destination status is
done_transition. A missing transition is reported, never guessed.
The same key can also serve the project's Jira issues in the Tasks hub (trackers.md).
Enabling a GitHub, Linear or Jira source seeds its cursor to the moment it was created, not the beginning of time — otherwise the first poll would treat every historical labelled issue as brand new and open one task per issue. Only issues/comments/updates from after that moment are ever considered.
Every event a source sees — matched or not — is one row in trigger_events,
visible in the project's Triggers section: timestamp, author, what happened
(task_created, skipped with a reason, or nothing recorded at all for
traffic that plainly did not match). This is also the dedup ledger: a second
delivery of an event already recorded is a no-op, checked by a database
unique constraint on (source_id, external_id), not by application logic
remembering to check first.
GET /api/projects/{id}/triggers— list a project's sources.POST /api/projects/{id}/triggers— create one (kind,name,config,secrets,interval_s).PATCH /api/triggers/{id}— update name/enabled/interval/config/secrets. An emptysecretsbody leaves stored secrets untouched.DELETE /api/triggers/{id}— remove a source and its event history.POST /api/triggers/{id}/test— a cheap, read-only connectivity check.GET /api/projects/{id}/trigger-events— recent events, newest first, with each event's task title/status inlined.
A source's secrets are never returned raw: the API redacts them to
{"field_name": true|false} (configured or not), the same pattern
project.mcp_json already uses for MCP server credentials.
internal/triggers is tested with:
- a fake
ghCLI script placed onPATHand driven through a realexecutor.Local(so it genuinely shells out, exercising the same code path production uses), plus a real local git repository with afile://-style local bare remote for the commit/push/PR postback path — no network needed; - an
httptestGraphQL server for Linear and a REST one for Jira; - a from-scratch minimal RFC 6455 server (
ws_test.go) driving the realwsDialclient, plus a full Socket Mode round trip (connect → app_mention envelope → ack → task creation → acknowledgement posted back) against that server and anhttptestREST server forapps.connections.open/chat.postMessage.
Run the whole suite through the reviewed isolated runner (see
CONTRIBUTING.md), never directly against the host.