The governance kit

integration.md

Wiring it in

The code that reads the documents. The loop, the two integration paths, and the promotion gate.

Wiring it in

The rest of the kit is about writing the documents. This one is about the code that reads them.

There is one idea underneath all of it, and if you only remember one sentence it should be this one:

The agent asks what governs it, does its work, and reports what it did.

Everything else in this document is detail on those three steps. The middle step — the actual agent — is yours, and RuleKeeper has no opinion about it. It does not want to be your framework. It wants to be the thing standing on either side of your framework, so that six months from now somebody can ask what governed a decision and get an answer instead of a shrug.


1. The loop, in full
  ask        →   what documents govern this agent right now, and at what versions
  work       →   your agent, your model, your tools, unchanged
  report     →   every action it took, and whether it stopped
  settle     →   outcome, tokens, cost, latency — on every path including a throw

The reason this is a loop rather than a logger is the first step. A logging library records what happened. This hands your agent its instructions and records the version of those instructions in the same breath, so the record and the rules can never disagree about which rules were in force.

That difference is the entire product. Write it down.


2. The five documents

Every agent is governed by exactly five documents, concatenated in this order to form its system prompt:

OrderDocumentWhat it answers
1system-promptWho the agent is and what it is for
2task-instructionsThe job, step by step
3behavioral-constraintsWhat it must not do
4tool-use-policyWhich tools, under what conditions
5escalation-pathsWhen to stop and hand to a person

Five, not one, because they change at different rates and are reviewed by different people. The task instructions change weekly. The behavioral constraints should change almost never, and when they do, somebody senior should have to sign it. One blob makes those the same act.

An agent missing any of the five refuses to run. Not a warning — a thrown error. An agent operating on an incomplete document set is unreviewable, and producing output nobody can assess is worse than producing none.

The version snapshot is the point. Every run is stamped with a map of document type to version:

{
  "system-prompt": "1.0.0",
  "task-instructions": "2.3.1",
  "behavioral-constraints": "1.1.0",
  "tool-use-policy": "1.0.2",
  "escalation-paths": "1.4.0"
}

Six months from now the question is never what does the prompt say today. It is what did it say when the agent did the thing being asked about. Without the snapshot the trace is a log. With it, the trace is an audit record.


3. Two ways to integrate

Pick one. They are the same model, reached from different directions.

Path A — the hosted control plane

Use this when the codebase is not yours, or belongs to a client, or you want governance to outlive one application. This is what a licensee does.

One file, client.ts. No dependencies, no build step, global fetch. It runs under Node 18+, Bun, Deno, and edge runtimes. It is deliberately readable in five minutes, because a file somebody will actually audit before pointing it at production agents is worth more than a package they install on faith.

import { createClient } from "./rulekeeper-client";

const rk = createClient({ apiKey: process.env.RULEKEEPER_API_KEY! });

await rk.run("claim-triage", { input: { claim } }, async (ctx) => {
  // ctx.systemPrompt   the five documents, assembled
  // ctx.governance     the version snapshot, already stamped on the run
  const answer = await yourAgent(ctx.systemPrompt, claim);

  await ctx.recordAction({
    toolName: "dispatch_adjuster",
    arguments: { vendor: answer.vendor },
    status: "succeeded",
    externalRef: answer.workOrderNumber,
  });

  return { output: answer };
});

rk.run() is the only call worth using. It opens the run, settles it on the way out, and settles it as an error if your work throws. A run left open forever is the one record shape worse than no record at all, because it is indistinguishable from work still in progress — a dashboard showing three weeks of work in flight that finished three weeks ago.

The rest of the surface, if you need the pieces separately:

rk.governance(agentId, { candidateVersionId? })   what governs this agent now
rk.registerAgent({ id, purpose, owner, status })  declare an agent
rk.startRun({ agentId, input, ... })              open a run
rk.recordAction(runId, action)                    log a tool call
rk.raiseEscalation(runId, { gateId, reason })     stop, and say why
rk.finishRun(runId, { outcome, output, ... })     close it
rk.startEvalRun({ agentId, suiteVersion, ... })   open a scoring run
rk.reportEvalCases(evalRunId, cases)              report graded cases
Path B — embedded, against your own database

Use this when the app is yours and you want no network hop between the agent and its governance. The Safeguard OS build does it this way.

You copy four files into the app — runtime.ts, langgraph.ts, eval-harness.ts, contract.ts — and they read your own Postgres directly through your existing Supabase service client. If you are on LangGraph, the whole integration is one wrapper:

import { runGoverned } from "@/lib/agents/governed";

export async function runAllowanceClassifier(input, candidateVersionId?) {
  const { runId, output } = await runGoverned(
    {
      agentId: "allowance-classifier",
      modelName: "anthropic/claude-sonnet-4.5",
      tools: allowanceClassifierTools,
      stepBudget: 12,
    },
    input,
    async ({ invoke, escalate }) => {
      const { transcript } = await invoke(buildRequest(input));
      const result = await structure(transcript);

      if (result.escalated) {
        await escalate("gate.classifier-uncertainty", result.reason, { input });
      }

      return { output: result };
    },
    candidateVersionId,
  );

  return { runId, classification: output };
}

Why a callback and not a wrapper that owns the whole run. A wrapper that owned everything would have to own what agents do after the model call — structured output, post-hoc validation, the second model call some of them make — and would grow into a framework of its own. So the caller keeps the middle and the wrapper keeps the edges, which is where the governance actually lives:

  before    documents loaded, versions frozen, run opened
  during    your logic, with an invoke() that logs as it goes
  after     tokens totalled, run closed, priced — on every path, including throw

That is the answer to "how does this not become a framework?", and it is worth being able to say out loud.


4. Wiring it into a codebase, in order

Step one. Declare the agent. An id, a purpose, an owner. The owner is a person, not a team. An agent with no name against it is an agent nobody will turn off.

Step two. Write the five documents. Use governing-document.template.md, and if you want the interview run for you, the document builder agent will do it. Version them from the start — 1.0.0, not "current".

Step three. Wrap the call site, not the agent. Whatever your agent already is, it keeps working. You replace the line that builds its system prompt with a call that fetches the documents, and you wrap the invocation so the run opens and closes around it. Nothing about the agent's own logic changes. This is the part people expect to be hard and it is not.

Step four. Declare the tools as grants. Each tool gets a registered side_effect_class: read, write, external, or irreversible. The blast radius comes from the grant, not from the caller. A client that could label its own irreversible tool as a read would put itself outside every conformance check by changing one string, so the server refuses to take your word for it. An action against a tool with no grant is flagged: the agent reached something nobody declared it could reach.

Step five. Record actions with their external reference. When the receiving system hands back an id — a work order number, a payment id — store it. That field is the difference between "the agent says it dispatched a crew" and "here is the work order it created."

Step six. Build the eval suite. Every constraint in the documents becomes at least one case. Each case declares:

  • id — stable, so a re-run overwrites rather than duplicates
  • trapthe specific way a plausible agent gets this wrong
  • clause — the section of the document it tests, e.g. §5.2
  • expect — what correct looks like

The trap field is required, not optional. A case whose failure mode cannot be named is a case testing that the code runs, and there are cheaper ways to learn that. The clause sends a reviewer to the rule rather than to the test source — otherwise a red case is an opinion somebody encoded on a Tuesday and nobody can later say whether the agent broke or the test was always wrong.

Step seven. Record a baseline. Run the suite against the live documents and mark it as the baseline. That is now the number every future change is measured against.


5. The promotion gate

This is the part the job description asks for by name, and the part worth demoing.

A change to a governing document does not go live. It is written as a draft version and it governs nothing. To evaluate it you run the real agent, against the real suite, with the draft substituted for the live document of the same type:

await runEvalSuite("allowance-classifier", { candidateVersionId: 47 });

The suite runs the actual production code path. There is no special test mode, because a draft scored by a different code path than the one that will run it has been scored against nothing.

Then the comparison. Baseline scored 46 of 46. The candidate scores 41 of 46. Promotion is refused. The draft stays a draft.

The scenario to walk somebody through: it is Friday, the agent is escalating too often and the queue is backing up, so somebody edits the behavioral constraints. Two constraints removed, one softened from a prohibition into a preference. Nothing about it looks reckless in a diff — it reads like tuning. The suite catches it because five fixtures derived from those exact clauses stop passing.

LangSmith would report the score. This refuses to ship. That is the line, and it is a real distinction rather than a marketing one: observability tells you what happened, and a gate decides whether it is allowed to.


6. Things that will bite you, and why they are the way they are

Escalation is a pass. Wherever a case expects the agent to stop, stopping scores as success. Get this wrong and the agent learns not to stop, and the gates it was given become decoration that still reports green. This is the single scoring decision most likely to be wrong in a way nobody notices, so it lives in the shared contract rather than in each suite.

Escalating on shape rather than substance also fails. An agent that escalates everything is as broken as one that escalates nothing; it has just moved the work back to a person while reporting a clean record.

Fixtures must never hardcode a date that lives in the database. A migration once shifted every seeded date by 75 days and five fixtures across two suites failed in one command. The agents were quoting the data correctly. The fixtures were quoting data that had moved — and a regression suite with an undeclared dependency on seed data reports red for a change that touched neither the agent nor its documents. Resolve the date at grade time from the same table the agent read.

The suite is sequential, not parallel. Provider rate limiting produces failures that look exactly like regressions, and a flaky suite is worse than a slow one because people stop believing red.

The suite is resumable, and has to be. A suite is N sequential agent invocations; the slowest agent averages 67 seconds a case with a 119 second tail, so ten cases exceed any serverless limit. Two runs were killed mid-suite before resume existed, recorded 5 of 10 and 6 of 13, and looked exactly like a catastrophic regression rather than a timeout. Baselines read from complete_eval_runs, never eval_runs — an interrupted run is not a baseline.

Runs made by the suite are marked `run_kind: "eval"`. Without it the conformance screen reads adversarial fixtures as production traffic and reports your own test suite as an agent behaving badly.

Every step budget is a spend commitment. An agent that has not finished in N steps is looping, and the cost of letting it run is unbounded. Set one on every agent.


7. What you get on the other side

Four questions you can now answer with a record instead of an opinion:

  1. What governed this decision? The exact five documents, at the versions in force at that moment.
  2. What did it actually do? Every tool call with its arguments, its side effect class taken from the declared grant, and the external reference the receiving system gave back.
  3. Did anyone approve the irreversible thing? An approval is bound to the run it was granted in. An approval from somebody else's session does not carry over.
  4. Did this change make it worse? A pass rate against a named baseline, scored on the draft, before the draft governs anything.

An agent that cannot answer all four is not ungoverned because somebody was careless. It is ungoverned because nothing was ever written down that could be checked.