Abhed extensions

Status: 2026-09-06

An extension changes how the agent behaves without forking it. Extensions are separate processes speaking JSONL over stdin and stdout, so they can be written in any language and need no build step in Abhed.

The one rule

An extension may veto, never permit.

It can block a call, force it to an approval prompt, rewrite arguments before a tool runs, rewrite a result before the model reads it, drop messages before they are sent upstream, and add to the system prompt. It cannot turn a denied action into an allowed one.

This is the single line Abhed draws differently from Pi, whose extension API is otherwise broader and better. Pi's answer to permissions is to containerise or write an extension, which is coherent for a developer on a laptop. It is not available to a deployment that has to prove to an auditor what the agent was permitted to do, because a gate an extension supplies is one an extension can also remove.

Concretely: the policy engine evaluates hooks first so they can veto, which means a hook returning Allow would short-circuit the deny rules beneath it. Acceptable for a hook an operator compiled in; not for one an operator dropped into a directory. Host.PolicyHook is structurally incapable of constructing an Allow, and two tests hold that line — read those first if this ever changes.

Protocol

One JSON object per line, in each direction. Abhed writes a request; the extension writes exactly one reply.

// Abhed → extension
{"event":"tool_call","session_id":"s-1","tool":"bash","args":{"command":"rm -rf /tmp/x"}}

// extension → Abhed
{"block":true,"reason":"rm -rf is not permitted here","log":"blocked a destructive command"}

Events

EventWhenThe reply may
tool_callbefore a tool runsblock, ask, rewrite args
tool_resultbefore the model sees outputrewrite content, set is_error
contextbefore each model callkeep a subset of messages, by index
before_agent_startonce per runappend to system
before_compactbefore history is summarizedcancel it, or supply the summary
list_toolsonce at startupdeclare tools this extension provides
invoke_toolthe model called onereturn its result
session_start, session_endrun boundariesnothing; for setup and teardown

Declaring no events subscribes to all of them.

Reply fields

FieldEffect
blockrefuse the call; reason is fed back so the model can adapt
askforce an approval prompt even where policy would have allowed it
argsreplace the call's arguments
content, is_errorreplace a tool result
keepindices of the messages to send; [] sends none
systemtext appended to the system prompt
summarythe compaction summary to use instead of asking the model
cancelleave the history alone this time
tools, resultsee "Providing a tool"
logwritten to Abhed's log

An empty reply {} means no opinion, which is also how a crashed, hung or nonsensical extension is treated.

Configuration

{
  "extensions": [
    {
      "name": "guard",
      "command": "bash",
      "args": ["/opt/abhed/guard.sh"],
      "events": ["tool_call"],
      "timeout_ms": 5000
    }
  ]
}

Behaviour that is deliberate

Extensions combine toward the stricter answer. Where two disagree, the blocking one wins; where one asks for approval and another is silent, the call is asked. Load order therefore cannot change a verdict, which is what keeps the audit trail reproducible.

A failing extension is skipped, not fatal. One that crashes, hangs past its timeout, or replies with something unparseable is marked dead and skipped from then on; the agent continues under policy alone. Failing the session instead would trade a working agent for a broken one and protect nothing, since an extension could only ever have made a decision stricter.

A hung extension is not retried. The read is abandoned but the stream is not, so a later reply would be matched to the wrong request.

The extension sees the whole call. Including fields the model wrote itself, such as a bash description. That is deliberate — an extension judging a command should see the intent stated alongside it — but it means a naive whole-line match can fire on a description rather than the command. Match the field you mean.

Worked example

Block reads of anything matching a pattern:

#!/bin/bash
while IFS= read -r line; do
  tool=$(jq -r '.tool // ""' <<<"$line")
  path=$(jq -r '.args.path // ""' <<<"$line")
  if [[ "$tool" == "read" && "$path" == *secret* ]]; then
    echo '{"block":true,"reason":"secrets are off limits"}'
  else
    echo '{}'
  fi
done

Redact credentials from every tool result:

#!/bin/bash
while IFS= read -r line; do
  if [[ $(jq -r '.event' <<<"$line") == "tool_result" ]]; then
    jq -c '{content: (.content | gsub("AKIA[A-Z0-9]{16}"; "[redacted]"))}' <<<"$line"
  else
    echo '{}'
  fi
done

Providing a tool

An extension can add a tool the harness never had. It answers list_tools once at startup, and invoke_tool when the model calls it:

#!/bin/bash
while IFS= read -r line; do
  case "$line" in
    *'"event":"list_tools"'*)
      echo '{"tools":[{"name":"ticket","description":"Look up a ticket by id",
             "schema":{"type":"object","properties":{"id":{"type":"string"}}},
             "mutates":false}]}' ;;
    *'"event":"invoke_tool"'*)
      id=$(jq -r '.args.id' <<<"$line")
      echo "{\"result\": \"$(fetch-ticket "$id")\"}" ;;
    *) echo '{}' ;;
  esac
done

A provided tool is a tool like any other: it appears in the model's list, its call goes through the policy engine, and its call and result are recorded as events. Providing one adds a capability, never a way around the rules — a deny rule naming it still wins, and one that mutates is subject to approval exactly as a built-in is.

Two defaults are deliberately strict. A tool that does not say whether it mutates is assumed to, because Abhed cannot know what someone else's tool does and the safe answer is the one that asks. And a duplicate tool name is refused rather than resolved by load order, since which tool ran would otherwise depend on the order extensions happened to start, and a policy rule naming it would be ambiguous.

The list is read once at startup. A tool set that changed mid-session would mean the model's prompt no longer matched what it could call, and would invalidate the prefix cache on every change.

Driving Abhed from another language

abhed rpc speaks line-delimited JSON on stdin and stdout, so a caller in any language can run it as a subprocess without a server, a port or auth:

p = subprocess.Popen(["abhed", "rpc"], stdin=PIPE, stdout=PIPE, text=True)
send(method="start", mode="auto")
send(method="prompt", prompt="fix the failing tests")
# every event the agent records arrives as {"type":"event", ...} while it works
MethodEffect
startopen a session; takes workspace, mode, allow, deny
promptsend a prompt, get the final answer
steerredirect a run already in progress
usagetokens, turns, compactions
exportthe HTML transcript
providersprovider types this build supports
quitclose

Events stream as they happen rather than only at the end, so a caller can render progress. steer is why this is a persistent process rather than one request per run.

Custom model providers

A provider no longer needs a rebuild either:

{
  "custom_providers": [
    {
      "name": "internal-vllm",
      "api": "openai",
      "base_url": "https://llm.internal.example/v1",
      "sampling": ["temperature", "top_p", "top_k", "seed"]
    }
  ]
}

api is named rather than guessed from the URL: a wrong guess produces rejected requests whose errors point nowhere near the cause. The sampling list is what the endpoint honours, and a parameter outside it is refused at startup rather than silently ignored.