Identities for entities did.bot
agent llm did
README.md

didbot-hookd #

The Claude Code hook handler. One binary, didbot-hook, that the harness runs for every hook event: it reads a payload as JSON on stdin and writes a hook response as JSON on stdout.

event effect
SessionStart provision an account for the conversation, remember its DID
SubagentStart provision an account for the subagent
PreToolUse on mcp__vibescrobble__scrobble rewrite the call to carry that agent's DID and the turn's identifier, provisioning an account first if the agent has none here
SubagentStop delete the subagent's account
SessionEnd delete the session's account and any subagent accounts left

Any other event returns an empty response and does nothing.

The hook never fails the session. An unreachable personal data server, an error response, an unparseable payload or a corrupt state file are reported on stderr and turned into an empty successful response, and the process always exits 0. With the server switched off the only symptom is that scrobbles go unstamped, and the MCP server refuses unstamped scrobbles.

That tolerance is why SessionStart is an optimisation rather than a precondition. A session that opened while the server was down has no account and cannot get one from SessionStart again — the harness reads hook configuration once, at session start, so the event has been and gone and restarting the server does not bring it back. PreToolUse therefore provisions too, on the first scrobble of a session that has no usable account. The identity still comes from the harness payload and never from the model, which is why this is a hook and not something the scrobble host offers.

The same branch covers a second case: an account minted by a different server. A DID means nothing away from the server that issued it, so a session pointed at a new personal data server mints a new account there rather than stamping a DID the target has never heard of.

Setting it up #

Install the binaries so they are on PATH:

cargo install --path crates/didbot-hookd
cargo install --path crates/didbot-mcp

Add the scrobble server to .mcp.json in the project root. The server name here is what makes the tool appear to hooks as mcp__vibescrobble__scrobble:

{
  "mcpServers": {
    "vibescrobble": {
      "command": "didbot-mcp",
      "args": ["--pds", "http://localhost:3000"]
    }
  }
}

Add the hooks to settings.json — .claude/settings.json for one project, ~/.claude/settings.json for every project:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          { "type": "command", "command": "didbot-hook" }
        ]
      }
    ],
    "SubagentStart": [
      {
        "hooks": [
          { "type": "command", "command": "didbot-hook" }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "^mcp__vibescrobble(-[a-z0-9-]+)?__scrobble$",
        "hooks": [
          { "type": "command", "command": "didbot-hook" }
        ]
      }
    ],
    "SubagentStop": [
      {
        "hooks": [
          { "type": "command", "command": "didbot-hook" }
        ]
      }
    ],
    "SessionEnd": [
      {
        "hooks": [
          { "type": "command", "command": "didbot-hook" }
        ]
      }
    ]
  }
}

The matcher is a regular expression over the tool name and is anchored so that the PreToolUse hook runs only for the scrobble tool. Without it the hook would be spawned for every tool call in the session; it would still return an empty response for anything else, but at the cost of a process per call.

It is a pattern rather than the literal tool name because a machine may run more than one scrobble host, and the server name is what the tool name is built from: vibescrobble and vibescrobble-beta export mcp__vibescrobble__scrobble and mcp__vibescrobble-beta__scrobble. One anchored expression covers every host this project declares, so this block is written once and never edited again. didbot-setup apply writes it for you.

didbot-setup apply does all of the above, after showing what it would change, and didbot-setup undo takes it back. The blocks are written out here because a person setting this up by hand should be able to see what the command would do.

Then start the development server, which listens on http://localhost:3000 with the defaults this hook assumes:

cargo run --bin didbot-dev

The hook does not decide permissions — it only rewrites the tool input — so the scrobble tool is subject to the usual permission flow. To stop being asked, allow it in the same settings.json:

{
  "permissions": {
    "allow": ["mcp__vibescrobble__scrobble"]
  }
}

Following one scrobble across three processes #

The stamp carries the payload's tool_use_id as well as the DID. The scrobble host logs it and forwards it to the personal data server in a quernstone-turn header, and the server puts it on the span every line of that request is emitted under — so one string joins this hook's line, the host's line, the server's lines, and the turn in the session transcript.

The harness's identifier rather than a fresh one, because a new one would correlate this project's three processes with each other and with nothing else. It is never written to a record: a repository is public and permanent, and an operational identifier does not belong in one.

didbot-setup debug on turns the detail up everywhere at once, and is what makes this hook say which turn it stamped — the ordinary path is silent on success, because the scrobble host's own feed already shows what was said.

One server, spelled two ways #

http://localhost:3000 and http://127.0.0.1:3000 are different strings and the same machine, and this hook does not decide which spellings of a host mean the same thing — two personal data servers really are two, and guessing wrong would stamp a DID into a call the target has never heard of.

So when the recorded server does not match the configured one, it asks: an account the configured server turns out to hold is adopted and re-recorded, and only a genuinely absent one is minted again. Without that, a session whose server was named one way and is now named the other dead-ends — provisioning refused because the account already exists, the scrobble unstamped, and the same on every scrobble after it.

Configuration #

A hook is invoked with a command line it does not control, so everything is an environment variable.

variable default meaning
DIDBOT_PDS_URL resolved, then http://localhost:3000 base URL of the personal data server
DIDBOT_SHARED_SECRET quernstone-placeholder-secret development attestation secret
DIDBOT_NODE_ID dev-node node id the attestation claim is made under
DIDBOT_HOOK_STATE $XDG_STATE_HOME/didbot/hook-agents.json state file path

The default secret is checked into this repository, printed by the development server on startup, and identical on every machine. It authenticates nothing.

$XDG_STATE_HOME falls back to ~/.local/state.

DIDBOT_PDS_URL set explicitly always wins. Unset, the server is resolved from the working directory on the hook payload against ~/.config/didbot/stack.toml — see didbot-stack — and a machine with no such file gets http://localhost:3000, which is what the development scripts bind. Resolution happens once, when an account is minted, and the answer is recorded beside the DID; a subagent that creates a worktree and moves into it keeps the account it was given, because that account exists on the server it was minted on and nowhere else.

State #

hook-agents.json maps an agent — a session, or a subagent within one — to the DID provisioned for it, and separately to the server that minted it:

{
  "agents": {
    "9f1c4e2a-7b3d-4c11-9a2e-5d6f7a8b9c0d": "did:web:9f1c4e2a-1f0c3d0e6b2a4d55.agents.localhost%3A3000",
    "9f1c4e2a-7b3d-4c11-9a2e-5d6f7a8b9c0d.agent-abc123": "did:web:9f1c4e2a-9a8b7c6d5e4f3021.agents.localhost%3A3000"
  },
  "servers": {
    "9f1c4e2a-7b3d-4c11-9a2e-5d6f7a8b9c0d": "http://localhost:3000",
    "9f1c4e2a-7b3d-4c11-9a2e-5d6f7a8b9c0d.agent-abc123": "http://localhost:3000"
  }
}

servers is what makes a DID checkable against the server it is about to be sent to. It is a second map rather than a field on each entry so that a hook built before it existed still reads the file: one binary serves every checkout on a machine and the last one to run dev-pds.sh wins, so a file this hook wrote is routinely read by an older one. An unknown key is ignored; a changed value shape is not, and an older hook that could not parse the file would forget every account in it and leak the ones it can then no longer delete.

An entry missing from servers was written before it existed, and matches whatever is configured — an upgrade must not decide that a running session's account belongs somewhere else.

One thing this cannot detect: a server restarted without --data is a factory reset, and its URL is the same before and after, so every DID on file still looks current while naming an account that no longer exists. Asking the server is the only way to know, which is didbot-setup check's job rather than this hook's.

Hook processes are short lived and concurrent, so every write goes to a temporary file in the same directory and is renamed over the target: a reader never sees a partial file. A file that will not parse is reported and treated as empty, and the next write repairs it.

The DID's leftmost label is derived from the agent, not copied from it. See src/agent_id.rs for the derivation and why it is not simply the session id.

Troubleshooting #

The hook's diagnostics go to stderr, which the harness shows in the hook's transcript entry. To see them directly:

echo '{"session_id":"test-session","cwd":"'"$PWD"'","hook_event_name":"SessionStart"}' \
  | didbot-hook

A successful run prints {} on stdout and a provisioned ... line on stderr. If it prints not provisioning ..., the server is not reachable or refused the attestation claim. A scrobble that goes out unstamped says no usable account for ... on <url>, which means provisioning has now failed twice — once at SessionStart and once on the scrobble itself — so the server is the thing to look at rather than the hook configuration.