Harness
Examples

An escalation webhook, the local runner, and the smallest tracker connector that is honest.

View raw Markdown · llms.txt · For agents

Three ways to wire the loop into a setup it did not come with. None of them needs a fork.

1. An escalation webhook

The tracker comment is the record; the channel is what goes on top of it.

notifications:
  events: [contract.escalated, plan.escalated, issue.paused, stage.paused, pr.merge-refused, release.waiting]
  webhook:
    urlEnv: LOOP_WEBHOOK_URL
    method: POST
    timeoutMs: 10000

urlEnv and not url: a versioned config never holds a secret. loop doctor warns when the variable is unset in this environment, because an unreachable webhook looks exactly like silence.

The payload is generic — summary, event type, issue, and the full event as JSON — which is why Slack, Discord, a Telegram bot and n8n all work without a line of vendor code. For a local machine, the other shape:

notifications:
  command: [terminal-notifier, -title, 'loop', -message, '{summary}']

2. The local runner

connectors:
  runner: local
  local: { worktreeRoot: ../.ak-worktrees, tmuxBin: tmux, cronMarker: '# ak-harness' }

git worktree, tmux, crontab. No app, no daemon. See running without Orca for what changes (chiefly: usage reporting comes from each CLI instead of one aggregated source).

3. The smallest honest tracker connector

import type { TrackerConnector } from '@agentskit/harness'

export const createFileTracker = (root: string): TrackerConnector => ({
  id: 'file',
  // The queue: whatever your system considers ready, newest first.
  queue: async () => readQueueFile(root),
  // One issue, in full: title, description, comments, labels, state, updatedAt.
  issue: async (identifier) => readIssueFile(root, identifier),
  comment: async ({ issue, body, dedupeKey }) => appendComment(root, issue, body, dedupeKey),
  transition: async ({ issue, to, reason }) => setState(root, issue, to, reason),
  // …labels, claim/release, attach, create
})

Register it in resolveConnectors and add its id to the connectors.tracker enum. Two things decide whether it is honest:

  • dedupeKey must actually deduplicate. The loop calls comment on every pass; without deduplication a stuck issue collects the same paragraph forty times, and people stop reading the ticket.
  • issue() must return what the contract needs: the description and the comments, because acceptance criteria are usually in the comments, and updatedAt, because that is what invalidates a cached contract.

Everything else — tick, deliver, release — stays untouched. That is the test of the seam: if adding a tracker makes you edit a stage, the interface was hiding a vendor rather than describing a role.

A plugin, when configuration is not enough

export default function register(bus) {
  bus.hook('beforeMerge', ({ issue }) => isChangeFreeze()
    ? { block: true, reason: `change freeze: ${issue} waits` }
    : undefined)
}

See writing a plugin for the eight hooks and the rules that keep a broken one from taking the loop down with it.

On this page