Tracker provider
This page is the build recipe for a new tracker backend. It is for an extension author wiring Lorenz
to an issue source the engine does not yet speak to. A tracker backend is one package that implements
the TrackerProvider contract from @lorenz/tracker-sdk and one line that registers it. The core
(config parsing, the dispatch loop, the MCP server, the CLI) is provider-agnostic and reaches every
backend through a registry, so a new tracker needs no core changes.
The linear and jira trackers are the worked examples;
extensions/linear-tracker/ is the most complete reference. test/tracker-extension.test.ts is the
executable form of this recipe: it builds a fake notion provider from SDK surface alone and drives
config parsing, dispatch validation, and client creation through it. If a new backend
needs more than the steps below to keep that test green, the provider boundary has regressed.
The TrackerProvider hook table
TrackerProvider lives in packages/tracker-sdk/src/provider.ts. Only kind and createClient are
mandatory; every other hook is optional and the core degrades cleanly when it is absent.
| Hook | Called by | Purpose |
|---|---|---|
kind |
TrackerRegistry |
the provider selector matched against tracker.kind (e.g. "linear", "jira"); the registry key |
configAliases |
config parse | snake_case to camelCase alias map for this provider's option keys (e.g. { project_slug: "projectSlug" }); applied before parseOptions |
envFallbacks |
config parse | env vars consulted for shared tracker: fields left unset, keyed by field name (e.g. { apiKey: "LINEAR_API_KEY" }) |
defaultEndpoint |
config parse | endpoint used when tracker.endpoint is unset (e.g. https://api.linear.app/graphql) |
parseOptions(options, context) |
config parse | validate and normalize the provider's keys (aliases already applied); the returned record becomes settings.tracker.options; throw tracker.<key> ... on bad input |
validateDispatch(settings) |
CLI startup (validateDispatchConfig) |
throw when parsed settings cannot drive dispatch (missing credentials or required options) |
createClient(settings, context) |
runtime | build the RuntimeTrackerClient that feeds candidate issues into the dispatch loop |
defaultToolPacks(settings) |
MCP mount | names of the registered ToolProvider packs this tracker owns and mounts by default when it drives dispatch; omit it and a registered pack whose name equals tracker.kind is mounted as a fallback |
projectUrl(settings) |
TUI / dashboard | operator-facing URL of the tracked project |
TrackerContext (parseOptions, createClient) carries env: NodeJS.ProcessEnv and, at parse time
only, resolveSecret(value, fallbackEnvVar?) for $VAR and op:// references.
The hooks fire in distinct phases. At config-parse time, configAliases and envFallbacks rewrite
the raw bundle, then parseOptions validates it into settings.tracker.options. At startup,
validateDispatch runs once to reject undispatchable settings before the loop starts. At runtime,
createClient produces the polling client. When the MCP server mounts tools, defaultToolPacks
decides the agent-facing surface. projectUrl is read by the dashboards.
The RuntimeTrackerClient contract
createClient returns a RuntimeTrackerClient, defined in packages/domain/src/index.ts. This is
the minimum the dispatch loop needs from any backend, small enough that the in-process memory tracker
can stand in for a real one.
interface RuntimeTrackerClient {
fetchCandidateIssues(): Promise<Issue[]>;
fetchIssuesByIds(ids: string[]): Promise<Issue[]>;
fetchIssuesByStates?(states: string[]): Promise<Issue[]>;
acknowledgeIssue?(issue: Issue): Promise<boolean>;
watch?(
onChange: (change?: TrackerChange) => void,
): TrackerChangeStream | null | Promise<TrackerChangeStream | null>;
fetchIssueEvents?(
issueId: string,
sinceTs: string,
query: TrackerIssueEventQuery,
): Promise<TrackerIssueEventPage>;
}
fetchCandidateIssues()returns issues currently eligible for dispatch: those whose state is intracker.active_states, filtered by the configured assignee where the backend supports it. The runtime applies routing labels, blockers, and concurrency caps afterward; the client does not.fetchIssuesByIds(ids)re-fetches specific issues by tracker id and preserves the requested order. The runtime calls this to refresh an issue it already knows.fetchIssuesByStates(states)is optional. It backs best-effort flows, notably terminal-state workspace cleanup at startup. A backend that cannot answer state queries cheaply omits it, and the caller skips those flows.acknowledgeIssue(issue)is optional. After a successful claim, the runtime starts it alongside agent setup so a provider can expose immediate human-visible feedback. It returnstruewhen it wrote an acknowledgement. Failures are observable and never fail the claimed run.watch(onChange)is optional. It opens a live change stream that nudges an immediate poll. A change can carry human-authored issue events, which the runtime forwards to active runs without waiting for that poll. Authenticate each event author and setauthorizedForSteeringonly when the provider's steering policy permits that author to direct the agent. The runtime ignores events without that authorization. Active runs retain the client instance that dispatched them, so a workflow reload cannot mix recovery or pushed events across tracker configurations. A provider that publishes issue events must implement the recovery feed and snapshot boundary below.fetchIssueEvents(issueId, sinceTs, query)recovers events missed across a change-stream or run-lifecycle gap. It is optional for providers that never publish issue events. Each event uses a unique positive decimaltskey. Zero is reserved for the empty snapshot cursor. Return the oldest events newer thansinceTsin ascending order, limited byquery.maxEventsandquery.maxBytes, and sethasMorewhen another page is available. Stop the request whenquery.abortSignalaborts. If one message exceeds the page byte limit, useboundTrackerIssueEventTextfrom@lorenz/domainto preserve its ordering key and author while shortening its live-delivery text. The complete message remains on the issue. Return only events authorized by the same steering policy used for live delivery, withauthorizedForSteeringset to true. SetIssue.issueEventCursorto the latest event key already represented in prompt-visible fields of each issue snapshot, or"0"when the snapshot contains no events. The runner ignores live replays at or before that immutable boundary, accepts recovery pages before newer live events, and advances its recovery cursor only through events accepted into bounded queued turns. The runner submits each accepted prompt to the session queue immediately within a bounded aggregate queue, then activates it after an issue refresh confirms that the issue is active and the effective backend profile is unchanged. A run submits later human events even when the autonomous turn budget is exhausted and accepts at mostagent.max_turnssteering turns; additional prompt-visible events remain eligible for the next attempt.
Each client returns the domain Issue shape, not the backend's raw payload. Issue requires
stateType: IssueStateType (one of backlog, unstarted, started, completed, canceled,
triage); normalizing the backend's status into that field is the provider's job. The Linear client maps Linear states, the
Jira client maps Jira statusCategory.key. Keep the raw payload on the issue for the agent to read.
The options-bag pattern
Like every extension axis, a tracker keeps provider-specific config out of the shared
TrackerSettings type and behind an opaque settings.tracker.options bag that parseOptions
validates once and a typed accessor reads back; see architecture.md
for the general pattern. The tracker-specific helpers parseOptions draws on live in
packages/tracker-sdk/src/options.ts:
rejectUnknownOptions(options, known, kind)throws on a typo'd key.stringOption(options, key)reads one string.stringListOption(options, key)reads a list; an empty list collapses toundefined.resolveEnvReference("$VAR", env)resolves an env reference.
stringOption and stringListOption shape their error as tracker.<key> must be ..., so an operator
sees the exact failing key. rejectUnknownOptions reports the typo'd keys together as unsupported tracker option(s) for kind "<kind>": <keys>. The Linear provider's parseOptions and its accessor
pair up like this:
parseOptions(options, _context) {
rejectUnknownOptions(options, ["projectSlug", "projectSlugs", "projectLabels"], "linear");
return {
projectSlug: stringOption(options, "projectSlug"),
projectSlugs: stringListOption(options, "projectSlugs"),
projectLabels: stringListOption(options, "projectLabels"),
};
}
// extensions/linear-tracker/src/options.ts
export function linearTrackerOptions(settings: Settings): LinearTrackerOptions {
const options = settings.tracker.options;
return {
projectSlug: stringOption(options, "projectSlug") || undefined,
projectSlugs: stringListOption(options, "projectSlugs"),
projectLabels: stringListOption(options, "projectLabels"),
};
}
Every other hook reads its config through linearTrackerOptions(settings), never through raw
settings.tracker.options keys. extensions/jira-tracker/src/options.ts does the same with
jiraTrackerOptions(settings). Unknown kinds parse leniently (the options pass through verbatim) and
only fail at validateDispatchConfig, which throws unsupported tracker.kind: <k> (known kinds: ...).
The agent-facing tools
A tracker exposes agent tools by implementing defaultToolPacks(settings), which returns the names of
the registered ToolProvider packs (each a separate provider in @lorenz/tool-sdk) that mount by
default when this tracker drives dispatch. The tracker owns and registers those packs; mounting is by
name. A tracker that declares no defaultToolPacks ships no tools, unless a registered pack happens to
share its tracker.kind, which the MCP mount falls back to.
Each built-in tracker owns its own pack:
- Jira owns the pack named the literal string
"jira", defined inextensions/jira-tracker/src/tools.ts. It servesjira_read_issue,jira_query,jira_update_status,jira_list_comments,jira_comment,jira_update_comment, andjira_create_issueoverJiraClientorJiraMcpClient, selected bysettings.tracker.kind. Thejiraandjira-mcpproviders both declaredefaultToolPacks() => ["jira"]. - Linear declares
defaultToolPacks() => ["linear"], mounting thelinearpack that exposes the singlelinear_graphqltool and bundles thelorenz-linearskill. - The
localtracker mounts itslocalpack:local_update_status,local_comment,local_create_issue,local_read_issue, andlocal_query. - The
slacktracker mounts itsslackpack:slack_update_status,slack_comment,slack_read_thread,slack_query,slack_user_info, andslack_channel_context. - The
discordtracker mounts itsdiscordpack:discord_update_status,discord_comment,discord_read_thread,discord_query,discord_user_info, anddiscord_channel_context. - The
memorytracker declares nodefaultToolPacks, so it advertises no tools.
The select/filter projection for jira_query lives alongside the pack in
extensions/jira-tracker/src/tools.ts, where the seven tool-name definitions and
DEFAULT_SELECT = [id, identifier, title, state, stateType, labels, url] are declared. Tool packs are
their own recipe; see tool-pack.md. The jira_* surface is detailed in
reference/jira-tools.md.
The recipe
Adding a tracker is a new package plus one registration call.
Create the package. Add
extensions/<name>-tracker/withpackage.jsonnamed@lorenz/<name>-tracker. Depend on@lorenz/domainand@lorenz/tracker-sdk(and@lorenz/tool-sdkif you ship a tool pack), each asworkspace:*. The dependency-cruiser ruleextensions-depend-on-sdk-layers-onlyblocks any import of an engine package; your code may reach onlydomainand the SDKs.Implement and export the provider. Write the
TrackerProviderinsrc/provider.ts: setkind, implementcreateClient, and add the optional hooks you need. Keep provider config insettings.tracker.optionsbehind a typed accessor insrc/options.ts. Normalize backend payloads into the domainIssueshape insrc/client.ts.Export an idempotent register function. Add
src/register.tsexportingregister<Name>Tracker(registries?). Default to the process-wide registries and skip a kind that is already present, so calling it twice is safe:export function registerNotionTracker( registries: { trackers?: TrackerRegistry; tools?: ToolRegistry } = {}, ): void { const trackers = registries.trackers ?? defaultTrackerRegistry; if (trackers.get(notionTrackerProvider.kind) === undefined) { trackers.register(notionTrackerProvider); } }Wire it into the composition root.
registerBuiltinBackends()inapps/cli/src/daemon.tsis the single place backend identity is hardcoded. Import your register function and add one call inside it, alongsideregisterLinearTracker,registerJiraTrackers,registerLocalTracker,registerMemoryTracker,registerSlackTracker, andregisterDiscordTracker. That is the one registration line.Install and reference the package. Run
pnpm installto link the new workspace package, then add areferencesentry pointing at../../extensions/<name>-trackertoapps/cli/tsconfig.jsonso the project build picks it up.
After this, tracker.kind: <name> in a workflow selects your backend. No core package is touched.
See also
- trackers/linear.md - the most complete worked example of this contract.
- trackers/jira.md - a second backend with REST and MCP-proxied variants.
- extensions/tool-pack.md - the separate axis for agent-facing tools your tracker can ship.
- reference/jira-tools.md - the seven
jira_*tools the Jirajirapack serves. - architecture.md - how the four extension points and the composition root fit together.
- reference/configuration.md - every
tracker.*config key the registry selects on.