An internal tool for managing the team's contracts. Three screens: a login, an agreements register, and an assistant.
The register, imported from a live Docusign account. The ESIGNATURE chips mark rows
built from envelopes rather than Agreement Manager records — which is also why their
value and expiry columns are empty. An envelope doesn't carry a term.
You need Docker Desktop running — that's where the database lives.
pnpm install
pnpm db:up # starts Postgres in Docker
pnpm db:migrate # creates the tables
pnpm dev # http://localhost:3000Stop the database with pnpm db:down. Your data survives — it lives in a Docker
volume, not in the container.
Create an account at /sign-up and you'll land on the register.
| Route | What it is |
|---|---|
/sign-in |
The login screen. /sign-up creates an account. |
/agreements |
The register — every contract you own, with status and renewal dates. |
/chat |
The assistant. |
/ isn't a screen; it sends you to the register when signed in and the login screen
when not.
Everything runs inside one Next.js app. There are three things outside it: Postgres, the Claude Agent SDK, and Docusign — and Docusign is reached twice, once for OAuth and once, through the agent, for the actual work.
flowchart TB
subgraph browser["Browser"]
s_auth["/sign-in and /sign-up"]
s_register["/agreements — the register"]
s_chat["/chat — the assistant"]
end
subgraph server["Next.js server"]
subgraph edge_layer["Route handlers and server actions"]
r_auth["/api/auth<br/>Better Auth"]
r_oauth["/api/docusign<br/>connect and callback"]
r_chat["/api/chat<br/>streams NDJSON events"]
a_sync["syncAgreements"]
a_rows["agreements page query<br/>delete, samples, loadMessages"]
end
session["session.ts<br/>every handler re-reads the session and<br/>scopes its query to that user"]
subgraph agent_lib["Agent — the only Docusign client in the app"]
g_run["agent/run.ts<br/>one chat turn, narrated"]
g_sync["agent/sync.ts<br/>headless turn, returns JSON"]
g_options["agent/options.ts<br/>no built-in tools, no settings sources,<br/>one grant: mcp__docusign__*"]
end
subgraph ds_lib["Docusign"]
d_sync["docusign/sync.ts<br/>snapshot upsert, prune"]
d_merge["docusign/agreements.ts<br/>map statuses, merge, de-duplicate"]
d_conn["docusign/connection.ts and oauth.ts<br/>token store, refresh, consent flow"]
end
dbl["lib/db — Drizzle<br/>agreement, conversation, message, docusign_connection"]
end
subgraph external["Outside the app"]
sdk["Claude Agent SDK<br/>Claude Opus 5 over Claude Code OAuth"]
mcp["Docusign MCP server"]
dsauth["Docusign account service"]
pg[("Postgres")]
end
s_auth --> r_auth
s_register --> a_rows
s_register --> a_sync
s_register --> r_oauth
s_chat <-->|"NDJSON: text, tool calls, register-changed"| r_chat
s_chat --> a_rows
s_chat -.->|"re-imports in the background"| a_sync
r_auth --> session
r_chat --> session
a_sync --> session
a_rows --> session
r_oauth --> d_conn
r_chat -->|"prompt, resume session id"| g_run
r_chat -->|"access token"| d_conn
a_sync --> d_sync
d_sync --> g_sync
g_sync -->|"two lists as JSON"| d_merge
d_merge -->|"merged rows"| d_sync
g_run --> g_options
g_sync --> g_options
session --> dbl
r_chat -->|"messages, tool calls, session id"| dbl
a_rows --> dbl
d_conn --> dbl
d_sync -->|"upsert on external key, prune what Docusign dropped"| dbl
g_options --> sdk
sdk -->|"bearer token, MCP connection only"| mcp
mcp --> dsauth
d_conn -->|"consent, code exchange, refresh"| dsauth
dbl --> pg
Two paths are worth tracing. A chat turn goes /chat → /api/chat → run.ts →
the SDK → Docusign's MCP server, and streams back token by token; if the agent
changed something upstream, the browser quietly re-runs the import. A register
sync goes the same way but headless — sync.ts asks the agent for both Docusign
lists as JSON, agreements.ts merges them, and the result replaces the imported rows
in Postgres.
Real: accounts and sign-in, the database, the agreements table with search, filtering and delete, chat threads that persist across refreshes, the Docusign OAuth connection, an assistant that actually calls Docusign, and a register imported from your real Docusign account.
There is deliberately no form for creating agreements — they come from Docusign, via Sync with Docusign. If you'd rather see the screen populated without connecting an account, Load sample agreements on the empty state writes ordinary records you can delete like any other.
Not in this version: contract PDF uploads, teams and sharing, notifications, and an audit log.
The chat is backed by an agent built with the Claude Agent SDK, running Claude Opus 5. Its one capability is Docusign, reached through Docusign's remote MCP server.
Every Docusign call is surfaced as it runs — here the agent reaches for Agreement Manager to answer a question about the portfolio, which is the routing the system prompt asks for. The table is for display; Agreement Manager is the query layer.
| File | What it does |
|---|---|
src/lib/agent/config.ts |
The model and the prompts — edit the agent's behaviour here |
src/lib/agent/options.ts |
What the agent is allowed to be, defined once for both callers |
src/lib/agent/run.ts |
Normalises a chat turn into UI events |
src/lib/agent/tools.ts |
MCP tool naming, shared with the client |
src/lib/agent/events.ts |
The NDJSON wire format between the route and the browser |
src/app/api/chat/route.ts |
Auth, persistence, and the streaming response |
Four things are worth knowing before you change it.
It signs in as you, not as an API key. The SDK resolves credentials in a fixed
order and an API key beats everything else, so buildAgentEnv in config.ts deletes
ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN from the agent's environment — one
exported in your shell for something unrelated would otherwise take over silently and
bill that account. What's left is Claude Code's own OAuth, read from
~/.claude/.credentials.json. Set CLAUDE_CODE_OAUTH_TOKEN (from
claude setup-token) only where there's no interactive login to inherit. Note that
running on a Claude Code subscription means the agent shares your rate limits — and
Anthropic asks that products offered to other people use API-key auth instead.
The agent has no tools of its own. The Agent SDK is Claude Code as a library, so
left alone it arrives with a shell and a file editor. options.ts sets tools: [] to
switch all of that off and grants exactly one thing back — mcp__docusign__*. It
also sets settingSources: [] and strictMcpConfig: true, so your personal
~/.claude config, the repo's CLAUDE.md, and .mcp.json have no effect on how the
app behaves for its users. Both callers — the chat turn and the register sync — share
that one builder, so the headless one can't quietly end up more permissive.
The Docusign token only authorises the MCP connection. It goes into an
Authorization header on the MCP server config and nowhere else — never into the
prompt, never to the browser. getValidAccessToken refreshes it first if it's stale.
Threads resume by SDK session. conversation.agent_session_id points at the
transcript the SDK keeps on disk, which is what lets "yes, send it" refer back to the
envelope proposed a turn earlier. It's disposable: if the session can't be resumed the
turn starts fresh rather than failing. Postgres, not the SDK, is the durable record.
Replies stream token by token, and every Docusign call the agent makes is shown in
the transcript as it runs — then saved to message.tool_calls so it survives a
refresh.
The agreements table is built from Docusign, and Docusign answers the question twice:
- eSignature knows about envelopes the moment they're sent, including ones still going round for signature. It's the only source that can show something in flight — but an envelope carries no value and no term.
- Agreement Manager knows about agreements once they exist as agreements, with the counterparty, the value and the dates. Richer, but it has nothing to say about the envelope you sent thirty seconds ago.
So both are read and merged. The same item appears in both — once as the envelope that carried it, once as the agreement it became — so rows are de-duplicated on the envelope id, falling back to the Agreement Manager id. Where an item is in both, the Agreement Manager record wins field by field, with the envelope filling any gaps it left.
| File | What it does |
|---|---|
src/lib/agent/sync.ts |
Runs a headless agent turn that returns both lists as JSON |
src/lib/docusign/agreements.ts |
Reads that payload, maps Docusign's statuses, merges the two sources |
src/lib/docusign/sync.ts |
Writes the merged list into the register |
Three things are worth knowing.
The agent is the Docusign client here too. There's no second, direct integration — the MCP server decides what the tools are called, and a hand-written REST client beside it would be a copy that drifts. The agent does the reading and the judgement calls (which tools exist, what a document is); everything mechanical — the status vocabulary, the de-duplication, which record wins — is plain code, so it behaves the same on every run.
It refreshes itself after a send. When a chat turn calls a Docusign tool that
changes something, the route emits a register-changed event and the browser
re-imports in the background, so an agreement sent in conversation is in the table by
the time you look. Pressing Sync with Docusign does the same thing by hand.
The table is for display; Agreement Manager is the query layer. Ask the assistant "every unsigned NDA this quarter" and it queries Agreement Manager — it does not read this app's database, and it's told not to. The merged table is what you look at, not what the agent thinks with.
Rows you added yourself are never touched by an import, and a Docusign row that no longer exists upstream is removed — an import is a snapshot, not an append.
Agreements and chat threads are private to each account. Every query filters on the
signed-in user, and every server action re-checks the session and re-asserts ownership
in its WHERE clause — a server action is a public endpoint, so it never trusts an id
sent from the browser.
Never drizzle-kit push. It leaves no migration history and will silently drop a
column with real data in it. Always:
pnpm db:generate # writes a reviewable SQL file into drizzle/
# read what it produced — a DROP COLUMN you didn't intend is obvious there
pnpm db:migrate # applies itThe drizzle/ folder is source code. Commit it.
Better Auth owns the user, session, account and verification tables. They're
generated, never hand-edited:
pnpm dlx @better-auth/cli@latest generate --config src/lib/auth.ts --output src/lib/db/auth-schema.ts -yRe-run that after upgrading better-auth, then generate and apply a migration.
.env is gitignored. It holds:
POSTGRES_URL— where the data lives.BETTER_AUTH_SECRET— signs login sessions. Regenerate withopenssl rand -base64 32.BETTER_AUTH_URL— the app's own address.DOCUSIGN_*— the Docusign OAuth connection. See the comments in.env.CLAUDE_CODE_OAUTH_TOKEN— optional, and unset in development. See below.
There is no ANTHROPIC_API_KEY. The assistant authenticates with Claude Code's
OAuth, so locally it just runs as whoever is signed in — if claude login works in
your terminal, the assistant works.
If Postgres isn't reachable, the app doesn't crash — it shows a notice telling you to
run pnpm db:up. Likewise, an expired Claude Code login or an unconnected Docusign
account surfaces as a message in the chat rather than a 500.
Config, not code. Docker Compose is a local convenience; nothing here deploys as a container.
- Point
POSTGRES_URLat a hosted Postgres in your host's environment variables. - Set
BETTER_AUTH_SECRETto a fresh value andBETTER_AUTH_URLto the real domain.
One direction, set in one place: the CSS variables at the top of
src/app/globals.css. Deep indigo primary, cool-grey neutrals, a tight 0.3rem
radius, Inter. Agreement status colours are there too, as .status-* classes keyed by
the database enum. Don't hard-code colours in components — change them here and both
light and dark themes follow.

