A pi-kit plugin is a pi package that talks to pi-kit through events on pi's shared bus. There is no second extension system: the package is a normal pi package (pi install, pi list, pi remove, pi config), its extension is a normal pi extension, and the events are how it asks pi-kit for what only pi-kit has: a thread's transcript and outcome, a model call under the attendant's cost cap, a place to hand a result back. The first plugin is @pi-kit/learn (ADR-0028); the second is @pi-kit/meat-proxy. This page is the contract; the schemas are in libs/shared/protocol/src/plugins.ts and every request is validated against them.
Status: built for @pi-kit/learn (with pi-kit:learn:verified) and extended for @pi-kit/meat-proxy (thread:baselines, thread:annotate, the transcript turns, the score task). Nothing is reserved any more.
An extension reaches the bus as pi.events. A request is one emit; the answer is an emit of pi-kit's on the channel's :reply:
pi.events.emit('pi-kit:thread:transcript', { requestId, thread? })
pi.events.on('pi-kit:thread:transcript:reply', (reply) => {
// { requestId, ok: true, ...result } or { requestId, ok: false, code, error, model? }
})requestIdis the caller's own string; the reply carries it back, so several requests can be in flight.threadis a thread id, or the start of one when it is unique. Left out, it is the thread the extension is running in.- A failure has a
codefor the caller's decision and anerrorsentence for the person:invalid(the request does not fit its schema),not-found,consent,off,cap,no-model,conflict,failed. - Only pi-kit answers, and it says so first: pi's bus is synchronous, and pi-kit emits
<channel>:acceptedwith{ requestId }inside the request'semit, before it works on the request. Subscribe to<channel>:acceptedand<channel>:replybefore emitting; when theemitreturns and nothing was accepted, nobody is listening (the pi command line, or pi-kit without that host) and the command says that pi-kit is needed, at once. No clock decides that, and the reply has no deadline: a slow model is given its time.ask()inextensions/meat-proxy.tsis the worked example; copy it. - A request belongs to the session it was emitted in. When that session closes (the thread is closed, the backend quits, the session is opened again) while a request is open, pi-kit aborts what it started for it (a model call) and answers
{ requestId, ok: false, code: 'failed', error: 'The session closed before pi-kit answered.' }on the session's<channel>:reply; nothing of the old session reaches the next one. - Pressing Stop aborts the session's open requests and answers
{ requestId, ok: false, code: 'failed', error: 'The request was cancelled before pi-kit answered.' }. The session stays open and accepts fresh requests. A verification replay has its own thread and Stop; ending the original request's wait does not end that independent job. - A request that has no
requestIdis not answered (there is nobody to answer to).
pi-kit:composer (one way, ADR-0014)
Toolbar buttons and suggestion pills for the composer. pi.events.emit('pi-kit:composer', { op: 'action' | 'suggestion' | 'remove', id, ... }); pi-kit validates it with ComposerRegistrationSchema and shows it. No reply. @pi-kit/computer-use uses only this event, for the pill that offers to set up its macOS permissions.
The thread reduced for analysis, after the redaction pass (ADR-0019: known key shapes and the user's own patterns are masked; the per-folder "pass off" switch does not apply to text that goes to a model).
- Request:
{ requestId, thread? }. - Reply:
{ ok: true, transcript, draft }.transcriptisThreadTranscript:
| Field | What |
|---|---|
threadId, title, cwd |
the thread and its project folder |
prompts |
what the person asked, in order, at most 12, each at most 1500 characters |
toolCalls |
how many tool calls the thread made |
steps |
{ n, tool, target, outcome } per call, target being the file, command, pattern or address; more than 200 and the middle is cut (truncated) |
errors |
{ step, tool, target, message, recovered, recoveredBy }: a failed call, and the later call of the same tool and target that worked |
checks |
{ step, command, passed }: shell commands that check the work (npm test, vitest, pytest, tsc, a build, a linter) |
diff, changedLines, commitMessage, base, head |
the outcome's git diff base..head (cut at 14,000 characters), its size, the head commit's message; null / 0 without an outcome |
truncated, redactions |
something was cut to fit; how many places were masked |
draft is the thread's skill draft ({ id, name, state }) or null, so a package can stop before it spends a model call on a thread that has one.
turns. The same messages as prompts, with what came of each, for a package that measures the person's part. One entry per user message, in order:
| Field | What |
|---|---|
n, at, text |
the 1-based number, when it was sent (epoch ms, or null) and the text as typed (a message with skill chips shows the sentence, not the expanded instructions) |
queued |
pi took the message from its queue the moment the turn before ended (a follow-up or a steer within 2 s of the previous entry): nobody waited for it |
gapMs |
milliseconds between the previous entry of the session and this message: the person's time between turns; null for the first |
toolCalls, files |
how many tool calls followed it, and the files pi edited or wrote while working on it (relative to the project, at most 40) |
chips |
the skill and prompt-template chips it carried |
review |
the review comments that travelled with it (file, side, line, endLine, text, snippet; ADR-0012), or null |
answers |
the dialogs the person answered while pi worked on it (method, title, status, confirmed or value; ADR-0025). pi keeps no record of these, so pi-kit does (thread_dialog_answers) |
previous |
the end of what pi said before the message (at most 400 characters), for rules like "quoting pi's own words" |
Everything in a turn goes through the redaction pass like the rest of the transcript.
How the thread ended (ADR-0028 §1): merged, committed or pr.
- Request:
{ requestId, thread?, diff? };diff: trueadds the unified diff. - Reply:
{ ok: true, outcome, diff? }.outcomeisThreadOutcome(kind,base,head,merge,prUrl,at,files,baseBranch,baseSource) ornull. annotations: trueaddsannotations: { hunkLabels, scores }: what plugins stored with the outcome throughpi-kit:thread:annotate, including the person's corrections of the labels (hunkLabels.corrections), so a package can show them to its model as examples. Both arenullfor a thread nobody scored.- The diff is
git diff --no-renames --relative -U3 base..headin the project's folder: paths relative to the folder, the same hunks the Outcome view shows. Hunk ids (below) are derived from it.
One model call through the attendant: its model (Settings › Attendant), its daily cost cap, its activity list. The package writes the prompt; pi-kit sends it, prices it and lists it.
- Request:
{ requestId, thread?, task: 'learn' | 'score', system, user, consent? }. The task names the activity kind the call is listed under and picks the consent sentence ("…to draft a skill" or "…to attribute each change to what you did"); the agreement is kept per thread and per task. - Reply:
{ ok: true, text, model, inputTokens, outputTokens, cost }. - With the attendant off, the first call for a thread answers
{ ok: false, code: 'consent', error, model }:erroris the sentence to show ("This sends the thread's transcript … to model …") andmodelthe model's name. The package asks the person (ctx.ui.confirm) and sends the request again withconsent: true; pi-kit remembers the answer for that thread. With the attendant on there is no question: the person switched it on. - Other failures:
cap(the day's cap is reached),no-model,failed(the request itself).
Hands a draft skill back. pi-kit validates it, adds the provenance (x-pi-kit: the thread, the outcome's base and head commits, verified: null), and keeps it as pending in its database, with a card at the end of the thread and a chip on the sidebar row. It never writes a skills folder: only the person's Approve does.
- Request:
{ requestId, thread?, text, model? };textis aSKILL.md. - Reply:
{ ok: true, draft }(SkillDraft), orinvalid(not a skill pi would load),conflict(the thread has a draft already: one per thread).
The files the thread's runs changed, read from the snapshots pi-kit takes of the thread's folder before and after each run (trees in a private git repository outside the project), never their content (which may be large or private). A file of the outcome that is not among them was changed outside pi. Empty for a folder outside a git work tree.
- Request:
{ requestId, thread? }. - Reply:
{ ok: true, files }, each{ path, existed, at, turn, edits }: relative to the project;existedfalse when the thread created the file;atwhen the run that first changed it started (epoch ms);turnthe 1-based message in which pi first touched it, or null when the session does not say (a background agent's write);editshow manyeditandwritecalls touched it.
Hands labels and scores back. pi-kit stores them with the thread's outcome (thread_outcomes.hunk_labels and scores, which put never touches), and draws them while the package is installed (see "Removable without a trace"): the badge's second line and menu, the Outcome view's meter and hunk labels, the project histogram in Settings.
- Request:
{ requestId, thread?, base, head, model?, labels?, scores? }.baseandheadare the outcome the labels describe; when it moves on (a later commit) pi-kit says the labels are out of date.labelsis a list ofHunkLabelEntry(id,file,header,label,input,turn?,trace,lines,excerpt?,by,was?);scoresisOutcomeScores(meat,vibe,hunks,steered,lines,steeredLines,byLabel,byInput,line). Left out leaves the stored value,nullclears it. - New
labelsreplace the package's earlier ones; the person's corrections of them stay (a relabelled hunk in the Outcome view is kept per hunk id and shown to the model as an example the next time), and when there are any, pi-kit computes the score again over the labels as they stand with the package's own arithmetic. - Reply:
{ ok: true }, ornot-found(the thread has no outcome),invalid.
Hunk ids. A hunk is named by its file and the two start lines of its @@ header exactly as git prints them: src/a.ts@10:12 (@@ -10,6 +12,8 @@; a new file is x@0:1). The package derives them from the diff, the Outcome view from its diff model; both read the same git diff, so a stored label finds its hunk on screen.
Replays the thread's draft skill from the commit the thread started at and answers with the report (ADR-0028 §3). pi-kit makes a scratch worktree at the outcome's base commit (under the worktree root, never the main checkout), starts a thread there titled "Verify: name" (visible in the sidebar, stoppable) with the draft pinned as a skill chip and the thread's first prompt, lets it run to the end, compares what it did with the outcome's git diff base..head (files and hunks, a similarity score), runs the thread's checks again, and removes the worktree. The reply comes when that is over, which takes a minute to many: the caller waits for it with no deadline (there is no clock on a replay; the person stops it from the thread), and a session that closes first gets the closed answer while the replay runs on under pi-kit's own verification job.
- Request:
{ requestId, thread?, start?, prompt? }.start: falseonly reads the last report without starting a replay.promptreplaces the thread's first prompt for this run. - Reply:
{ ok: true, draft: { id, name, state }, verification }.verificationisSkillVerification:state(done,stopped,error, orrunningfor a read), and fordonetheresult(pass|partial|failed), thescoreof the whole diff (0 to 1),files({ reproduced, expected, extra }),hunks({ matched, expected, actual }),fileList(path, statussame | similar | different | missing | extra, similarity, hunks),checks(the command, whether it passed in the thread, whether it passed again, the exit code, the last lines of its output),summary(one sentence with the numbers),warnings, the replayedprompt,model, the verificationthreadIdandbase/head.erroris a replay that could not be carried out (the project is not trusted, no worktree could be made, the model failed, pi stopped to ask a question): it says nothing about the skill. - Failures:
not-found(no thread, or the thread has no draft: run/learnfirst),conflict(no outcome to replay from, a discarded draft, or that draft is being verified already; replays of different drafts run side by side),invalid,off,failed. - What counts. A file is reproduced when the lines it added and removed are at least 80 % the same as the outcome's (Dice coefficient over the changed lines, trailing blanks ignored); a hunk is matched one to one the same way.
pass: every file reproduced, the whole diff at least 90 % alike, and no check that passed in the thread fails now.failed: no file reproduced, or the whole diff under 30 % alike. Anything between ispartial. The same words are in the review dialog and, asx-pi-kit.verified(result,at,files,hunks,checks,similarity,replayedFrom), in the draft's frontmatter and in theSKILL.mdApprove writes, only for a replay of exactly that text. - Safe by construction. The replay's session works in the scratch worktree, not in the project; it is a normal pi session, so nothing stops a tool from writing elsewhere by absolute path, and pi-kit looks at the main checkout before and after and warns when it changed. Nothing is pushed or merged. A verification thread is marked as one, never gets an outcome, is not drafted from and is left alone by the attendant. It is archived afterwards as the record of the run and cannot run again.
packages/pi-kit-learn/
package.json name @pi-kit/learn, keywords [pi-package], pi.extensions
extensions/learn.ts /learn [thread] and /learn verify [thread]
lib/analysis.mjs the prompt over the transcript, and reading the model's answer (no dependencies)
lib/verification.mjs a verification report as a sentence and a level for ctx.ui.notify (no dependencies)
prompts/analyze.md the analysis prompt (system and user sections)
templates/SKILL.md the shape the model is asked to write
/learn in the composer (or pi with pi-kit running):
pi-kit:thread:transcriptfor the thread (stops if it already has a draft);analysis.buildAnalysisPrompt(transcript);pi-kit:attendant:runwith that prompt (askingctx.ui.confirmon aconsentanswer);analysis.parseDraftReply(text): aSKILL.md, orSKIP: reasonwhen nothing generalises;pi-kit:learn:draft; thenctx.ui.notifysays to read the card.
/learn verify [thread] asks the other way round: pi-kit:learn:verified for the thread's draft (waits for the report), then describeVerification(report, name) (lib/verification.mjs, no dependencies) turns it into one sentence and a level for ctx.ui.notify: "replay-notes: partial. Reproduced 5 of 6 files and 5 of 6 hunks; 1 of 1 check passes again. Read the report in the review dialog, then approve or discard the draft." The same replay starts from the review dialog (Approve with "Verify first" on, or Verify now), where the report is shown with its files and checks.
The attendant's own trigger. With the attendant on, an outcome (a worktree merged back, a commit made in the folder, a pull request) starts the same analysis without a session: pi-kit's backend loads lib/analysis.mjs from the installed package, so the prompt and the parsing stay in one place. It runs only when the thread looks worth it: at least 6 tool calls, at least 10 changed lines in the outcome, and no skill pi already lists whose name and description share at least 3 of the first prompt's key terms (words of four letters or more, without the common ones). It runs in the attendant's queue, under its cap and its per-project opt-out, and is listed in the activity as learn. Remove the package and nothing is drafted.
Installing and removing. The package ships inside the app (Resources/backend/packages/pi-kit-learn, dist/apps/backend/packages/pi-kit-learn in development). The first time pi-kit starts it adds that folder to the user's pi settings, the same entry pi install <folder> writes, once: pi list shows it, pi remove <folder> removes it, and pi-kit does not put it back (it remembers that it offered). If the app moves, the entry points at a folder that is gone; the next start points it at the new copy. PI_KIT_LEARN_PACKAGE=off skips the install. To get it back after a removal: pi install <app>/Resources/backend/packages/pi-kit-learn.
The second plugin has a different shape: it reads and annotates, it writes no files and runs no worktrees.
packages/pi-kit-meat-proxy/
package.json name @pi-kit/meat-proxy, keywords [pi-package], pi.extensions
extensions/meat-proxy.ts /score [thread]
lib/hunks.mjs the outcome diff split into hunks, with their ids
lib/ledger.mjs what the person put into the thread, without a model (no dependencies)
lib/attribution.mjs the prompt that asks the attendant's model to label every hunk, and the reading of its answer
lib/score.mjs the arithmetic and the sentence
lib/pipeline.mjs the whole scoring of one thread against a `host` that answers the events
prompts/attribute.md the attribution prompt (system and user sections)
/score in the composer (or pi with pi-kit running):
pi-kit:thread:outcomewithdiff: true, annotations: true, thenpi-kit:thread:transcript(itsturns), andpi-kit:thread:baselines;splitDiff(diff)andbuildLedger(...): per message its specificity (named files, line numbers, commands, code), whether it corrects (a small rule set: "no", "not that", "instead", "undo", "revert", quoting pi's own words, a review comment), its chips, answers, tool calls and time; per file what led to it; the files only the person's hand changed, which aremanualby rule and never shown to the model;pi-kit:attendant:runwithtask: 'score'and the prompt (askingctx.ui.confirmon aconsentanswer): the model labels every other hunkspecified | corrected | autonomous | manualwith the input it traces to (prompt N,review N,hand,dialog,chip,none), one line each; large outcomes are processed in batches; an incomplete model answer fails without replacing the stored score;computeScores(labels): Meat Proxy = the share of changed lines in specified, corrected and manual hunks, Vibe = 100 − that, with the sentence that says what it is made of ("62 % steered: 14 of 23 hunks, 9 by the first prompt, 3 by review comments, 2 by hand");pi-kit:thread:annotatewith the labels and the scores; thenctx.ui.notifysays the sentence.
lib/pipeline.mjs is written once against a host (outcome(), transcript(), baselines(), run(prompt, progress), annotate(body)): the extension's host asks over the event bus; pi-kit's backend, which scores after an outcome lands (the attendant on) and from the badge menu without a pi session, loads the same file from the installed package and answers the same questions from its own records. progress identifies the current batch and total batch count; the backend records that activity for each model call. The person's relabelling in the Outcome view does not call the model: pi-kit loads computeScores and effectiveLabels from the package and recomputes.
A plugin's data stays in pi-kit's database, but core draws it only while the package is installed in pi. pi remove <folder> of @pi-kit/meat-proxy (pi-kit looks at pi's settings every five seconds and when a score is asked for) leaves the badge with the outcome alone (no second line, no "Score this thread" in its menu), the Outcome view with plain hunks (no meter, no labels, no relabel control), Settings › Projects and trust without the histogram, and GET /api/threads/:id/score answering 404. pi install <folder> brings all of it back from what was stored. The same package installer serves every package that ships with the app, @pi-kit/computer-use included (apps/backend/src/plugins/pi-kit-package.ts): the first start adds each shipped copy to the user's pi settings once (PI_KIT_LEARN_PACKAGE=off, PI_KIT_MEAT_PROXY_PACKAGE=off, PI_KIT_COMPUTER_USE_PACKAGE=off skip it; installs run one after the other so no entry is lost), and a package the user removed stays removed.
A package needs keywords: ["pi-package"], a pi key in package.json naming its extension, and nothing from pi-kit to import: the events and their payloads are plain JSON. Keep logic that should also work without pi-kit (parsing, prompts) in a module the extension imports, as lib/analysis.mjs is. Do not start timers or sockets in the factory; ask from a command or from session_start.
What a plugin may rely on:
- Nothing reaches a skills folder, a thread or the person's files without their decision. A draft is data until Approve.
- Redaction first. A transcript is masked before it leaves pi-kit's backend, whatever the plugin asks.
- Cost is capped. A model call is the attendant's, listed with its cost; at the day's cap the call answers
cap. - Consent while the attendant is off. The transcript goes to a model only after the person agreed for that thread.
- Outside pi is derived, not tracked. A file of the outcome that no run of the thread changed (
pi-kit:thread:baselinesdoes not list it) was changed outside pi; an edit made by hand during a run counts as the run's. - A plugin never reaches into pi-kit's database. What it lacks is added to the events and documented here (added
baselines,annotate, the transcript'sturnsand the outcome'sannotationsfor this reason).