Skip to content

Trace a PR back to its session

PortalMCPCLIAPIIntermediate~10 min

Someone asks "who wrote this, and why?" about a merged pull request. Find out which of your AI teammates worked on it, what each one recorded doing (opening it, pushing to it, reviewing it, merging it), and which worker sessions those records came from. Ask your agent, or look it up in the portal's Worklog. Naming the person who drove a colleague's session, and the model it ran on, needs the hadron CLI or the API. The portal and your agent show a colleague's session id, but neither can open the session yet.

This is the question the whole Worker model exists to answer. It works on a team App whose workers recorded their work as it happened (see Set up an AI team). The worklog is append-only, so anything recorded is still there.

Ask your agent

What you need: an agent connected to Hadron, and access to the team App's worklog: membership of the App, Contributor or above on its organization, ownership of a user-owned App, or the App's own key (the full rule). Name the team and the pull request:

In our Hadron Dev Team, which workers worked on
hadron-memory/hadron-server#1362? For each one, tell me what it recorded
doing (opened, pushed, reviewed, merged), when it was recorded, and which
worker session the record came from. If there are more records than fit in one answer, fetch
them all.

What you get back: one line per recorded milestone, each with its worker, its action, when it was recorded, and its worker session. That time is when the record was made, which can be later than the work itself: a milestone can be recorded after the fact. For when a pull request was actually opened or merged, look at GitHub. A pull request worked across several sessions lists every one of them, which is correct. The worklog returns 50 records at a time and says how many there are in total; if the agent reports fewer than the total, ask it for the rest.

What to check before you rely on it:

  • What the records can and can't prove. They are milestones each worker logged, not commit authorship: GitHub's commit history is the record of who wrote which lines. Every record against the pull request comes back, so a worker who only reviewed or merged it appears next to the ones who built it. The action tells them apart: opened marks who opened the pull request, pushed marks a worker that pushed commits to it (possibly not the one who opened it), and reviewed and merged are neither. Ask the agent to show the actions if it doesn't.
  • A teammate, or a person. A milestone logged from a session that wasn't bound to a worker still comes back, but the name on it is the person's handle, not a teammate's. The portal's Worklog marks such a row as not attributed to a worker. If a name in the answer isn't one of your teammates, ask the agent whether that record has a worker at all.
  • A pull request, not an issue with the same number. GitHub numbers pull requests and issues from one sequence. Say "pull request" and ask the agent to look only at pull requests if the answer mentions an issue.
  • What it can't tell you. Your agent can say that you drove a session only when that session is among your open or recently ended ones, which is all it can see of your own. For an older session, or anyone else's, it can't name the person or the model. That needs the CLI or the API.

In the portal

  1. Open the portal, go to Apps, and open your team's App.
  2. Choose the Worklog tab.
  3. Paste the pull request into Artifact, as owner/repo#123 or as its GitHub URL. Set Kind to pr and choose Filter.

Each row is one recorded milestone, with columns When (when it was recorded), Worker, Action, Kind, Artifact, Tool and Worker session. When several worker sessions recorded work on the same artifact, each record is still its own row, and the Artifact cell also shows a badge such as 3 worker sessions; choosing it filters the worklog to that artifact. Compare the Worker and Worker session columns across those rows. The filter lives in the page address, so you can send the link to whoever asked.

The Worker session column shows the session's id, even for a session you can't open. A worker's own page lists only the sessions you drove as that worker, so a colleague's session isn't shown there.

The whole chain, with the CLI or the API

The rest of this page follows the chain to its end: pull request, worklog, session, person and transcript.

Step 1 — Ask the worklog who produced the artifact

hadron team session list --pr hadron-memory/hadron-docs#271
WORKER  ROLE           USER              TOOL  HOST  MODEL          STARTED                   TRANSCRIPT  SESSION
Tove    docs-engineer  019d28f166d979…   —     —     claude-opus-5  2026-08-25T08:29:43.359Z  —           01a0380a222c73af900eced52612c3bf
hadron_team_work_items(ref: "hadron-memory/hadron-docs#271", kind: "pr")
1 record(s) total, showing 1:

2026-08-26T10:16:40.563Z [Tove] pr opened hadron-memory/hadron-docs#271
  (session 01a0380a222c73af900eced52612c3bf)
query($app: ID!, $ref: String!) {
  teamWorkItems(appRef: $app, ref: $ref, kind: "pr") {
    total
    items { workerName sessionId kind action at }
  }
}

Spelling does not matter; the artifact KIND does. A PR can be written as a GitHub URL or as owner/repo#N — both normalize to the same record, so https://github.com/hadron-memory/hadron-docs/pull/271 and hadron-memory/hadron-docs#271 are interchangeable.

owner/repo@sha and owner/repo:branch are different artifacts, not other ways of writing a PR. On the CLI each kind has its own flag — --commit, --branch, --issue — and passing a commit ref to --pr finds nothing.

And owner/repo#N does not distinguish a PR from an issue. PRs and issues share GitHub's number space, so they share a canonical spelling and a ref-only lookup finds both. The CLI's --pr already says which you mean; on the other two surfaces, pass kind: "pr" — otherwise issue #271 comes back alongside PR #271.

Several rows are expected and correct. A PR spanning three sessions yields three transcripts. A recorded session you cannot read appears as an id-only stub rather than vanishing, so the count never silently understates the work.

But the rows answer "who touched this", not "who wrote it". Every record against the ref comes back — opened, reviewed, merged, closed — so a worker who only merged the PR appears beside the one who wrote it. The distinguishing field is action, which the MCP and GraphQL forms show and the CLI's human table does not: reach for --json when the difference matters.

Step 2 — Read the session for the rest

Step 1 gives you a session id. Turning that into the rest of the provenance takes the CLI or the GraphQL API — --json on the command above, or:

query($id: ID!) {
  session(id: $id) {
    workerName workerRole userId
    repo branch prNumber startedAt
    host tool transcriptPath llmModel
  }
}

MCP cannot read an arbitrary session

There is no MCP tool for it. hadron_whoami returns your own sessions, so it cannot open a colleague's. An MCP-only host can do step 1 — which worker, which session — but not step 2, and so not step 2b either: naming the person needs the userId that only step 2 returns. Both need the CLI or the API. That is why the page carries mcp and portal badges for the worklog lookup, but its full chain runs on cli and api.

--json gives you everything the bind recorded:

{
  "workerName": "Tove",
  "workerRole": "docs-engineer",
  "userId": "019d28f166d979d09438cd92e832194c",
  "repo": "hadron-memory/hadron-docs",
  "branch": "main",
  "prNumber": 271,
  "startedAt": "2026-08-25T08:29:43.359Z",
  "host": null,
  "tool": null,
  "transcriptPath": null,
  "llmModel": "claude-opus-5"
}

That chain — PR → worklog → session → transcript — is the provenance the Worker: commit trailer promises. How much of its last link you actually get depends on what the bind recorded, which is the next section.

Step 2b — Turn userId into a person

userId is an opaque id, and "who drove it" is usually the point. Once step 2 has given you the userId, resolve it on either surface:

hadron_search(query: "019d28f166d979d09438cd92e832194c", entityTypes: ["user"])
1 result for "019d28f166d979d09438cd92e832194c":

- [user] hrn:user:holger — Holger Selover-Stephan (matched pk, score 3.00)
query($ref: ID!) { user(ref: $ref) { urn handle name } }

user(ref:) takes the id or the URN, so this is also the step that turns a hrn:user:… you already have into a display name.

The provenance you get out is the provenance that went in

Look again at the sample above: tool, host and transcriptPath are null. Nothing is broken. That session was bound from an MCP host, where those are not passed, so they were never recorded.

None of them are backfilled, and none can be reconstructed later. If you want a transcript path in the answer, it has to be supplied at bind time:

hadron team session start --as Tove --tool claude-code \
  --host "$(hostname)" --transcript ~/.claude/projects/…/session.jsonl

So the useful habit is to decide before a stint what the person asking in six months will need, rather than discovering the gap while answering them.

The other direction — everything one worker has done

The same worklog answers the reverse question, which is often the one you actually have:

hadron_team_work_items(worker: "Tove")
11 record(s) total, showing 8:

2026-08-26T10:16:40.563Z [Tove] pr opened  hadron-memory/hadron-docs#271
2026-08-26T09:55:22.684Z [Tove] pr merged  hadron-memory/hadron-docs#270
2026-08-26T09:35:36.593Z [Tove] pr opened  hadron-memory/hadron-docs#270
…

A retired worker still resolves: retirement ends the casting, not the history.

Mind the envelope. The sample says 11 records total, showing 8 — the response is a page, not the set. total is the number to compare against, and limit / offset are how you walk the rest. "Everything one worker has done" is what the query means, not what one call returns.

Each step is gated more narrowly than the last

The three lookups do not share one permission, so a chain that starts fine can stop partway — and it stops by returning less, not by refusing:

Step Who can do it
1 — the worklog Any AppMember (any role), an org CONTRIBUTOR+, a user-owned App's owner, or the App's own key
2 — read the session The attributed driver, a member of the owning org, the session's App, or a platform admin
2b — name the person A caller sharing an organization with them, or a platform admin — anyone else gets null

So an AppMember who is neither the driver nor in the owning org sees the work happened and gets the id-only stub described above — that stub is this gate, not a missing record. And an org member tracing a PR driven by an external App member can open the session but cannot resolve the userId.

Neither is a bug to route around. If you need the answer and the gate says no, the route is to ask someone who has it, not to widen your own access.

Why Session.prNumber is not the index

A session row carries a prNumber, and it is tempting to query on it. Don't.

It is a denormalized display field — the convenience that lets a session list show a PR number without a join. Each pr record overwrites it, so it holds the most recent PR that session touched, not all of them. In the JSON above it reads 271 because that was the last one; the same session also produced #265, #266, #267, #268 and #270, and only the worklog knows that.

The worklog is the many-to-many join. prNumber is a label on one end of it.