Agents · 2026-05-17 · By RedDB team · 8 min read

Agent SDK on RedDB: building custom agents with native persistence

A walk through the four surfaces of a small Agent SDK — tool registry, memory adapter, conversation log, eval hooks — backed by RedDB instead of a stack of services. Ends with a working code-review bot in under 300 lines that remembers what it has reviewed, per repository.

If you have built an agent on top of a hosted CLI like Claude Code, you have probably hit the wall where the wrapper stops fitting. The conversation log is somebody else’s schema. The tool registry is a JSON file in a config directory. The memory is a markdown file the host parses on startup. The eval hooks, if they exist, run after the fact and write to a separate store. It works until you want to ask “what is the second-most-common false positive my review bot has produced on this repo over the last 90 days,” at which point you discover the data lives in four places and none of them join.

This post is about the alternative: a small, opinionated Agent SDK whose four surfaces all sit on a single RedDB instance. Not a framework — there is no scheduler, no agent-of-agents abstraction, no DSL. Just the four pieces every agent project ends up reimplementing badly, given names and a storage contract. The point is that once those four surfaces share a database, the queries that used to require glue code become one statement.

We end with a code-review bot. Under 300 lines, including the SDK plumbing, that remembers every review it has ever produced per repository, learns from human overrides, and lets you ask “what did I get wrong” in SQL.

The four surfaces

The SDK exposes exactly four things to the agent author. Adding a fifth is the moment you should reach for a framework instead.

Tool registry. A typed catalogue of the tools the agent can call. Tools are rows, not files. Each row stores the JSON schema for arguments, the handler reference, the rate-limit budget, and a hash of the implementation so the eval layer can tell when a tool’s behaviour changed underneath an old eval run.

Memory adapter. The agent’s read/write surface for facts it wants to remember. The SDK does not assume a single shape of memory — episodic, semantic, and procedural memory each have their own table, with the access patterns described in Building an agent memory layer on RedDB. The adapter is a thin facade that hides which table a remember(...) call lands in, based on the kind of fact.

Conversation log. Append-only stream of every turn: user input, model output, tool call, tool result, error. Indexed by conversation id, by time, and (for the entries with embedded content) by vector. The log is the source of truth — everything else, including the memory tables, is materialised from it.

Eval hooks. Two callbacks: onTurnComplete(turn) and onConversationComplete(conv). The SDK does not run evals; it gives the eval author a deterministic place to write feedback rows that join back to the conversation. Evals run as separate workers reading from the same database. There is no “send results to the eval service” step — the results are already there.

Four surfaces, one database, no service mesh.

The storage contract

The SDK ships with a migration that creates five tables. Names matter — the queries in the rest of this post and in the code-review bot below reference them.

-- 1. The conversation log. Append-only.
CREATE TABLE agent_turns (
  id            uuid PRIMARY KEY,
  conversation  uuid NOT NULL,
  agent         text NOT NULL,
  kind          text NOT NULL,  -- 'user' | 'model' | 'tool_call' | 'tool_result' | 'error'
  payload       jsonb NOT NULL,
  embedding     vector(1024),
  created_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX agent_turns_conv_time ON agent_turns (conversation, created_at);
CREATE INDEX agent_turns_embedding ON agent_turns USING hnsw (embedding vector_cosine_ops);

-- 2. The tool registry.
CREATE TABLE agent_tools (
  name          text PRIMARY KEY,
  agent         text NOT NULL,
  schema        jsonb NOT NULL,
  impl_hash     text NOT NULL,
  rate_budget   jsonb NOT NULL,  -- { window_seconds, max_calls }
  updated_at    timestamptz NOT NULL DEFAULT now()
);

-- 3. Memory: episodic, semantic, procedural.
CREATE TABLE agent_episodic  ( /* see agent-memory-layer-on-reddb */ );
CREATE TABLE agent_semantic  ( /* see agent-memory-layer-on-reddb */ );
CREATE TABLE agent_procedural( /* see agent-memory-layer-on-reddb */ );

-- 4. Eval feedback. Joins back to a specific turn.
CREATE TABLE agent_evals (
  id            uuid PRIMARY KEY,
  turn_id       uuid NOT NULL REFERENCES agent_turns (id) ON DELETE CASCADE,
  evaluator     text NOT NULL,    -- 'human', 'rubric:v3', 'llm-judge:gpt-4o'
  verdict       text NOT NULL,    -- 'good' | 'bad' | 'partial'
  rationale     text,
  payload       jsonb,
  created_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX agent_evals_turn ON agent_evals (turn_id);

The five tables are the entirety of the SDK’s storage surface. Everything else — replay, ranking, “what reviews has this bot done on this repo,” “which tool calls produced bad outcomes last week” — is one or two joins on this schema. The shape is deliberately boring.

A few decisions worth pointing at:

  • agent_turns.embedding is on the turn row, not in a sidecar table. The same write that logs the model’s output also writes its embedding, in one transaction. There is no two-step “log then embed” that can fail halfway.
  • agent_tools.impl_hash exists so the eval layer can refuse to compare a current verdict against a historical one when the tool implementation has changed. Without it, eval drift is silent.
  • agent_evals.turn_id is a foreign key with ON DELETE CASCADE. A retention policy on agent_turns automatically prunes the evals that referenced them, so you do not end up with orphan rows pointing at conversations that have aged out.

The SDK shape, in TypeScript

The code below is the entire user-facing surface. It is small on purpose; the value is in the storage contract above, not in the wrapper.

import { RedDB } from '@reddb/client';
import { embed } from '@reddb/embed';

export type Tool<A, R> = {
  name: string;
  schema: object;                       // JSON Schema for `args`
  handler: (args: A, ctx: Ctx) => Promise<R>;
  rateBudget?: { windowSeconds: number; maxCalls: number };
};

export type EvalHooks = {
  onTurnComplete?: (turn: Turn) => Promise<void>;
  onConversationComplete?: (conv: Conversation) => Promise<void>;
};

export class Agent {
  constructor(
    private db: RedDB,
    private name: string,
    private tools: Tool<any, any>[],
    private hooks: EvalHooks = {},
  ) {}

  async register() {
    await this.db.tx(async (tx) => {
      for (const t of this.tools) {
        await tx.upsert('agent_tools', {
          name: t.name,
          agent: this.name,
          schema: t.schema,
          impl_hash: hashFn(t.handler),
          rate_budget: t.rateBudget ?? { windowSeconds: 60, maxCalls: 30 },
        });
      }
    });
  }

  async run(conversation: string, userInput: string, ctx: Ctx): Promise<string> {
    // Append user turn (with embedding) in one transaction.
    const userEmb = await embed(userInput);
    await this.db.insert('agent_turns', {
      conversation, agent: this.name, kind: 'user',
      payload: { text: userInput }, embedding: userEmb,
    });

    // Hand the conversation to the model. Loop until the model stops
    // requesting tools. Each tool call and result is its own logged turn.
    let modelOut: ModelTurn;
    while (true) {
      modelOut = await callModel(this.db, this.name, conversation, this.tools);
      const t = await this.logTurn(conversation, 'model', modelOut);
      await this.hooks.onTurnComplete?.(t);

      if (!modelOut.toolCall) break;

      const tool = this.tools.find((x) => x.name === modelOut.toolCall!.name);
      if (!tool) throw new Error(`unknown tool: ${modelOut.toolCall.name}`);

      await this.checkRateBudget(tool);
      const result = await tool.handler(modelOut.toolCall.args, ctx);

      const callTurn   = await this.logTurn(conversation, 'tool_call',   modelOut.toolCall);
      const resultTurn = await this.logTurn(conversation, 'tool_result', result);
      await this.hooks.onTurnComplete?.(callTurn);
      await this.hooks.onTurnComplete?.(resultTurn);
    }

    await this.hooks.onConversationComplete?.(
      await this.loadConversation(conversation),
    );
    return modelOut.text ?? '';
  }

  // ...logTurn, loadConversation, checkRateBudget elided; ~40 LOC each
}

That is the whole SDK. register() writes the tool catalogue. run() walks one turn through the model, calling tools until the model stops asking. Every turn is logged with its embedding in the same write, and the eval hooks fire at the boundaries the eval layer cares about. There is no scheduler, no retry policy, no per-tool dependency injection — those belong to the agent author, not the framework.

The memory adapter is intentionally not in the class. It is a separate helper the tool handlers use directly:

export const memory = {
  remember:   (db: RedDB, kind: 'episodic'|'semantic'|'procedural', row: object) => ...,
  recall:     (db: RedDB, kind: ..., query: { text?: string; subjects?: string[] }) => ...,
  forget:     (db: RedDB, kind: ..., predicate: object) => ...,
};

Tools write to memory when they have something worth remembering. The SDK does not guess for them.

A code-review bot, end to end

The bot is one file. It reads a pull request, walks the diff hunk by hunk, asks the model for review comments on each hunk, posts them, and — the part that matters — remembers what it has reviewed per repository so it does not produce the same comment twice and so the human override signal feeds back into future reviews.

// review-bot.ts — ~250 LOC including the SDK calls above
import { Agent, memory } from './sdk';
import { RedDB } from '@reddb/client';
import { github } from './github';   // small wrapper around @octokit/rest

const db = new RedDB(process.env.REDDB_URL!);

const tools = [
  {
    name: 'fetch_pr_diff',
    schema: { type: 'object', properties: { repo: {type:'string'}, pr: {type:'number'} }, required:['repo','pr'] },
    handler: async ({ repo, pr }, ctx) => {
      const cached = await memory.recall(db, 'procedural', { subjects: [`gh:${repo}#${pr}`] });
      if (cached.length) return cached[0].value;

      const diff = await github.diff(repo, pr);
      await memory.remember(db, 'procedural', {
        cache_key: `gh:${repo}#${pr}`, value: diff, expires_at: in1h(),
      });
      return diff;
    },
  },
  {
    name: 'prior_findings',
    schema: { type: 'object', properties: { repo:{type:'string'}, hunk_hash:{type:'string'} }, required:['repo','hunk_hash'] },
    // The whole point of the bot: do not repeat yourself.
    handler: async ({ repo, hunk_hash }) => {
      return await memory.recall(db, 'semantic', {
        subjects: [`repo:${repo}`, `hunk:${hunk_hash}`],
      });
    },
  },
  {
    name: 'post_review_comment',
    schema: { type: 'object', properties: {
      repo:{type:'string'}, pr:{type:'number'}, path:{type:'string'},
      line:{type:'number'}, body:{type:'string'}, hunk_hash:{type:'string'},
    }, required: ['repo','pr','path','line','body','hunk_hash'] },
    handler: async (a) => {
      const url = await github.postReviewComment(a);
      // Remember the finding so prior_findings will surface it next time.
      await memory.remember(db, 'semantic', {
        subjects: [`repo:${a.repo}`, `hunk:${a.hunk_hash}`, `pr:${a.pr}`],
        text: a.body, attributes: { path: a.path, line: a.line, url },
      });
      return { url };
    },
  },
];

const hooks = {
  // When a human resolves or dismisses a comment, write an eval row so
  // future runs of the bot can see which findings were accepted.
  onTurnComplete: async (turn) => {
    if (turn.kind !== 'tool_result') return;
    if (turn.payload?.toolName !== 'post_review_comment') return;
    const override = await github.checkComment(turn.payload.url);
    if (override?.dismissedBy) {
      await db.insert('agent_evals', {
        turn_id: turn.id,
        evaluator: 'human',
        verdict: 'bad',
        rationale: override.reason ?? null,
      });
    }
  },
};

const agent = new Agent(db, 'code-reviewer', tools, hooks);
await agent.register();

export async function review(repo: string, pr: number) {
  const conv = newUuid();
  return agent.run(conv, `Review PR ${repo}#${pr}. Skip findings that match prior accepted findings on the same hunk.`, { repo, pr });
}

The body of the bot is small because everything that would normally be glue — caching the diff, storing findings, recording human overrides, joining all three for the next run — is just a table write. The line that matters is the one in prior_findings: every review the bot has ever posted on this repository, for this hunk, is one recall away. The bot can decline to repeat itself without anyone writing a deduplication service.

Asking “what did I get wrong”

This is the query that motivated the whole exercise. Given the schema above, the answer is two joins:

SELECT
  t.payload ->> 'body'      AS comment_body,
  t.payload ->> 'path'      AS path,
  count(*)                  AS times_dismissed,
  max(e.rationale)          AS last_reason
FROM agent_turns t
JOIN agent_evals e ON e.turn_id = t.id
WHERE t.agent = 'code-reviewer'
  AND t.kind  = 'tool_result'
  AND t.payload ->> 'toolName' = 'post_review_comment'
  AND e.verdict = 'bad'
  AND t.payload ->> 'repo' = $1
  AND t.created_at > now() - interval '90 days'
GROUP BY 1, 2
ORDER BY times_dismissed DESC
LIMIT 20;

The most-dismissed comments per repo over the last quarter. No exporters, no warehouse, no scheduled job that flattens the data into a third store. The conversation log is the source of truth and the analytics surface, because they are the same table.

Cross-conversation learning

The second query the bot benefits from is one the SDK enables for free. “Find a review I have written on this repo whose target hunk is semantically similar to the one I am about to review, and which a human accepted.” That is hybrid search on agent_turns joined to agent_evals:

SELECT t.payload, 1 - (t.embedding <=> $1) AS sim
FROM agent_turns t
LEFT JOIN agent_evals e ON e.turn_id = t.id
WHERE t.agent = 'code-reviewer'
  AND t.payload ->> 'repo' = $2
  AND (e.verdict IS NULL OR e.verdict <> 'bad')
ORDER BY t.embedding <=> $1
LIMIT 10;

The bot can prefer past wording that survived human review and avoid wording that did not. Without the embedding on the turn row, this query is a fan-out to a separate vector store with a separate consistency story; with it, it is a single planner call. The shape of the query is the planner walkthrough from Hybrid search done right applied to this exact use case.

What you give up

The SDK is small because it leaves a lot to the author. That is the trade.

  • No retry policy. If a tool’s network call fails, the handler decides whether to retry. The SDK logs every attempt as its own tool_call / tool_result turn, so observability is preserved, but it will not retry on your behalf.
  • No scheduler. One run() call equals one conversation. Concurrency and queuing are outside the SDK. (For agents that need a queue, see Sub-agent dispatch queue.)
  • No prompt templates. callModel is a leaf the author owns. The SDK does not impose a prompt structure; it imposes a storage structure.
  • No multi-agent coordination. Two agents talking to each other write to the same agent_turns table under different agent values, but the SDK does not route messages between them. That is the topic of Multi-agent shared memory.

These are deliberate omissions. Every one of them is something teams want to customise per project, and every one of them is something frameworks get wrong by trying to standardise.

Why this works on RedDB specifically

Every claim in this post — embeddings on the turn row, hybrid lexical+vector retrieval against the log, foreign-keyed eval rows that survive retention pruning, one-statement analytics across log and evals — depends on three database properties: a single transaction can write a row and its embedding; the query planner can mix lexical, vector, and structured predicates in one plan; and the same instance is the operational store and the analytics surface.

You can build the same SDK on a stack of Postgres + Redis + a vector service + a warehouse. People do. The cost is the integration code that keeps the four stores in agreement, and the queries you no longer write because they would require denormalising across all of them. The SDK above is small because that integration code does not exist.

If you want to try this against your own agent, the migration and the SDK scaffold above are enough to start. Point a fresh RedDB at it, swap in your model call, and the first run() will land a row in agent_turns that you can query in the same shell you are running the bot from. The five-table shape is the contract. Everything else is yours.

© 2026 RedDB.io. AGPL-3.0 self-host · Managed Cloud.