Skip to content

Latest commit

 

History

8 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Agreement Agent

An internal tool for managing the team's contracts. Three screens: a login, an agreements register, and an assistant.

The agreements register, showing three envelopes imported from Docusign eSignature

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.

Running it

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:3000

Stop 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.

The three screens

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.

How it fits together

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
Loading

Two paths are worth tracing. A chat turn goes /chat/api/chatrun.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.

What's real and what isn't

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 assistant

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.

The assistant answering a question, showing the Docusign tool calls it made

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.

Where the register comes from

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.

Your data is your own

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.

Changing the database schema

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 it

The 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 -y

Re-run that after upgrading better-auth, then generate and apply a migration.

Environment

.env is gitignored. It holds:

  • POSTGRES_URL — where the data lives.
  • BETTER_AUTH_SECRET — signs login sessions. Regenerate with openssl rand -base64 32.
  • BETTER_AUTH_URL — the app's own address.
  • DOCUSIGN_* — the Docusign OAuth connection. See the comments in .env.
  • CLAUDE_CODE_OAUTH_TOKENoptional, 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.

Going to production

Config, not code. Docker Compose is a local convenience; nothing here deploys as a container.

  • Point POSTGRES_URL at a hosted Postgres in your host's environment variables.
  • Set BETTER_AUTH_SECRET to a fresh value and BETTER_AUTH_URL to the real domain.

Styling

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.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages