Use case · Internal tools

An admin tool the ops team can ship work from, not a spreadsheet that someone has to keep in sync

One Next.js admin app per team, scoped to the workflows that repeat. Role-based access on top of the existing user table. Audit log on every write so the question of who changed what stops needing Slack archaeology. Fast tables, bulk actions, CSV in and out. An AI agent layer for the workflows that look like "read the support ticket, find the customer, refund the last invoice".

The problem

The ops team is a human ETL pipeline that the company keeps adding load to

The product worked, so the team grew. Customer success runs a Google Sheet that mirrors the customer table; the spreadsheet is two days behind real time. Finance exports invoices to a CSV every Friday and reconciles them by hand. Support reads a ticket, opens five tabs to find the customer, and DMs an engineer to issue a refund. Every new policy ends as a thread in #ops with a list of customer IDs to update one by one. The team grows; the manual work grows with it; the engineers who could automate it are busy with the product. We build the admin tool that fits the team's actual workflows. Each repeated action becomes a button or a bulk operation. Each cross-tab lookup becomes a side panel. Each Slack thread that ends in `please update these IDs` becomes a form. Every write goes through audit log. The ops team stops translating between systems and starts running them.

Our approach

Six steps from spreadsheets to an admin tool the team uses every day

Workflow audit before schema. Schema before screens. Screens before bulk actions. Bulk actions before AI agents. The order matters because each later step depends on the abstractions the earlier step produced.

  1. 01

    Audit the team's actual workflows

    We sit with every operations role for one hour each: customer success, support, finance, growth, the founder. Each session ends with a list of 5 to 15 repeated actions, ranked by frequency and pain. The output is a single document: 40 to 70 workflows total, half of them duplicates across roles, the top 12 covering 80% of the team's time. The doc is the spec for the rest of the engagement; everything in the build maps back to a workflow on this list.

  2. 02

    Schema and access

    We model the entities the admin tool needs to read and write: users, accounts, invoices, support tickets, feature flags, internal notes. Where the product schema already covers them, the admin reads from there. Where it does not (audit log, internal notes, agent transcripts), we add new tables with `internal_` prefix so they never leak into the product surface. Every table has RLS policies that let only the right internal roles see the right rows; an admin engineer cannot read PII of customers outside their assigned region.

  3. 03

    The admin shell

    A Next.js app at `admin.yourdomain.com`, separate auth from the product, behind your SSO of choice (Google Workspace, Microsoft Entra, Okta). Sidebar with the workflows from step 1, a tab per role, a global search across users, invoices, and tickets. The shell ships in week two with the top three workflows wired up; the team starts using it immediately and gives feedback in week three.

  4. 04

    Bulk actions and CSV in/out

    Every table view supports multi-select with a confirm dialog. Bulk update prompts for a justification (one line, stored in audit log) and runs in the background with a progress bar; the team can close the tab. CSV import for any operation the team does in batch; CSV export from any table view with a one-click button. The Google Sheet that mirrored the customer table is the first thing to disappear.

  5. 05

    Audit log on every write

    Every write through the admin lands in an `audit_log` table with the actor, the entity, the before/after diff, the IP address, and the user agent. The log is queryable and exportable; finance reads it during audits and security reads it after any incident. The product code is not touched; the audit log is a property of the admin write path.

  6. 06

    AI agent layer

    Workflows that look like 'read the support ticket, find the customer, take the action' get an agent. We use Anthropic Claude with tools that wrap the same admin actions a human would take. Every agent action goes through the same audit log; the agent runs as a named system user with explicit RLS scope. The agent saves the team time on routine work and produces a transcript a human can read when something looks off; the team keeps the steering wheel.

What we deliver

Workflow audit document

A spec listing 40 to 70 repeated workflows across the team, ranked by frequency and pain, mapped to roles. The document the rest of the build references; signed off by the team before code starts. Updated quarterly so the admin tool keeps tracking what the team actually does.

Admin schema

A set of `internal_` tables (audit log, internal notes, role assignments, agent transcripts, saved searches, dashboards) with RLS policies. The schema lives in the same Supabase project as the product but is named so it cannot bleed into the product surface.

SSO integration

SSO wired into the admin via the customer's chosen provider (Google Workspace, Entra, Okta). New hires get admin access the same week they join; departures lose access the day they leave. No second password to remember; no shared accounts.

Role and permission system

Roles (admin, ops, support, finance, read-only) with explicit permission scopes. RLS policies that read the role on every query. The permission matrix lives in a config file the team can read; the team knows who can do what by reading the doc, not by asking the engineer.

Admin shell with top-12 workflows

A Next.js admin app with the 12 highest-frequency workflows wired as first-class screens. The shell uses the design system; new screens drop into the existing patterns instead of inventing layout per page. The team logs in on day one and starts working.

Bulk-action framework

A reusable pattern for multi-select tables: confirm dialog, justification capture, background run, audit log row per change, progress UI. Every workflow that wants bulk gets the same UX; the team learns it once.

CSV import and export

Type-safe CSV import for every entity the team needs to batch-update, with row-level validation and a dry-run preview. CSV export from every table view. The pattern is one component; new tables get it for free.

Audit log explorer

A screen that surfaces the audit log with filters by actor, entity, time, and action. Finance opens it before audits; security opens it after incidents. Exportable as CSV with a one-click button.

Internal notes system

Notes attached to users, accounts, invoices, and tickets; visible to internal staff only; markdown supported; mentions trigger a Slack DM. Replaces the parallel CRM the team was running in Notion or Linear.

AI agent integration

Two to four agents wired to the workflows where the team is repeating the same chain of reads and writes. Each agent is named, scoped, and audit-logged. The team can hand off a ticket to the agent and review the transcript; the agent never acts without a record.

Operations dashboard

A dashboard with the four to eight numbers the founder asks about every Monday (MRR, active customers, churn, signups, support tickets open, refunds last 7 days). The dashboard reads from the same admin schema; the numbers are the same numbers the team works with day to day.

Runbook for the admin tool

A short README in the repo explaining how the admin tool is laid out, how to add a new workflow, and how to remove one when the team stops using it. The doc that prevents the admin tool from growing into the next mess.

Five files that compose an admin tool the team actually uses

The five files below compose the admin: the SSO middleware that gates every route, the role-based query helper that enforces RLS at read time, the bulk-action runner with audit log, the agent wrapper that uses Claude with tools, and the admin schema that defines what an admin can touch.

An internal admin build is a translation problem before it is a UI problem. The team already runs the business; the work just happens in the wrong tools. Spreadsheets are versionless and slow; ad-hoc SQL is fast and unsafe; Slack threads do not survive the people who wrote them. The admin tool turns those workflows into typed, audited, role-scoped actions the whole team can use without losing the speed of running them in a spreadsheet.

The five files below are the scaffolding the engagement leaves behind. The SSO middleware that gates the admin, the role-based query helper that enforces RLS at every read, the bulk-action runner with audit logging, the Claude agent wrapper, and the admin schema that defines what an admin role can touch.

1. The SSO middleware

Every admin route runs behind SSO. The middleware reads the session, looks up the user's role from the database, and either lets the request through or returns 403. No public endpoints. No "I forgot my password" flow. The customer's existing identity provider is the source of truth.

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { getServerSession } from '@/lib/auth/session'
import { lookupAdminRole } from '@/lib/admin/roles'

const ALLOWED_DOMAINS = ['adamarant.com']

export async function middleware(req: NextRequest): Promise<NextResponse> {
  const session = await getServerSession(req)
  if (!session) {
    return NextResponse.redirect(new URL('/auth/sign-in', req.url))
  }

  const email = session.user.email ?? ''
  const domain = email.split('@')[1]
  if (!ALLOWED_DOMAINS.includes(domain)) {
    return new NextResponse('forbidden', { status: 403 })
  }

  const role = await lookupAdminRole(session.user.id)
  if (!role) {
    return new NextResponse('not provisioned', { status: 403 })
  }

  const requestHeaders = new Headers(req.headers)
  requestHeaders.set('x-admin-role', role)
  requestHeaders.set('x-admin-user-id', session.user.id)

  return NextResponse.next({ request: { headers: requestHeaders } })
}

export const config = { matcher: ['/((?!auth|api/auth|_next).*)'] }

2. The role-based query helper

The helper reads the admin role from the request headers, opens a Supabase client scoped to that role, and runs the query. The Supabase JWT carries the role claim; the database policies enforce it. The admin engineer cannot bypass RLS even if they wanted to.

// lib/admin/db.ts
import { createClient } from '@supabase/supabase-js'
import { headers } from 'next/headers'
import { signRoleJwt } from './role-jwt'

interface AdminContext {
  role: 'admin' | 'ops' | 'support' | 'finance' | 'read_only'
  userId: string
}

export async function adminContext(): Promise<AdminContext> {
  const h = await headers()
  const role = h.get('x-admin-role') as AdminContext['role'] | null
  const userId = h.get('x-admin-user-id')
  if (!role || !userId) throw new Error('missing admin context')
  return { role, userId }
}

export async function adminDb(): Promise<ReturnType<typeof createClient>> {
  const ctx = await adminContext()
  const jwt = signRoleJwt({ sub: ctx.userId, role: ctx.role })
  return createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, {
    global: { headers: { Authorization: `Bearer ${jwt}` } },
  })
}

3. The bulk-action runner with audit log

A bulk action takes a list of entity IDs, a function to apply per ID, and a justification. It runs in batches with concurrency control, captures errors per row, and writes an audit row before and after each change. The UI shows progress; the team closes the tab and gets a Slack ping when the run completes.

// lib/admin/bulk.ts
import { adminContext, adminDb } from './db'
import { sendSlack } from '@/lib/integrations/slack'

interface BulkRunOptions<T> {
  entityType: string
  ids: string[]
  justification: string
  apply: (id: string, db: Awaited<ReturnType<typeof adminDb>>) => Promise<T>
  concurrency?: number
}

export async function runBulk<T>(opts: BulkRunOptions<T>): Promise<{ ok: number; failed: number }> {
  const ctx = await adminContext()
  const db = await adminDb()
  const concurrency = opts.concurrency ?? 5

  let ok = 0
  let failed = 0

  for (let i = 0; i < opts.ids.length; i += concurrency) {
    const batch = opts.ids.slice(i, i + concurrency)
    const results = await Promise.allSettled(
      batch.map(async (id) => {
        const before = await db.from(opts.entityType).select('*').eq('id', id).single()
        const next = await opts.apply(id, db)
        const after = await db.from(opts.entityType).select('*').eq('id', id).single()
        await db.from('audit_log').insert({
          actor_user_id: ctx.userId,
          actor_role: ctx.role,
          entity_type: opts.entityType,
          entity_id: id,
          action: 'bulk_update',
          before: before.data,
          after: after.data,
          justification: opts.justification,
        })
        return next
      }),
    )
    for (const r of results) {
      if (r.status === 'fulfilled') ok += 1
      else failed += 1
    }
  }

  await sendSlack({
    channel: '#ops',
    text: `bulk run on ${opts.entityType}: ${ok} ok, ${failed} failed (actor=${ctx.userId})`,
  })

  return { ok, failed }
}

4. The Claude agent wrapper

The agent is one function. It takes a task, a set of tools, and a context. Each tool is a small, audited wrapper around an admin action. The agent picks tools, calls them, and writes a transcript the team can read. The agent never has more permission than its role allows.

// lib/admin/agent.ts
import Anthropic from '@anthropic-ai/sdk'
import { adminContext, adminDb } from './db'

const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })

interface AgentTool {
  name: string
  description: string
  inputSchema: Record<string, unknown>
  run: (input: Record<string, unknown>) => Promise<string>
}

export async function runAgent(
  task: string,
  tools: AgentTool[],
): Promise<{ transcript: string; result: string }> {
  const ctx = await adminContext()
  const db = await adminDb()
  const transcript: string[] = []

  const response = await anthropic.messages.create({
    model: 'claude-opus-4-7',
    max_tokens: 4096,
    messages: [{ role: 'user', content: task }],
    tools: tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.inputSchema as Anthropic.Tool.InputSchema })),
  })

  // For brevity: one round of tool use. In practice this loops until the model returns text-only.
  for (const block of response.content) {
    if (block.type === 'tool_use') {
      const tool = tools.find((t) => t.name === block.name)
      if (!tool) continue
      const result = await tool.run(block.input as Record<string, unknown>)
      transcript.push(`tool=${tool.name} input=${JSON.stringify(block.input)} result=${result}`)
    } else if (block.type === 'text') {
      transcript.push(`text=${block.text}`)
    }
  }

  await db.from('agent_transcripts').insert({
    actor_user_id: ctx.userId,
    actor_role: ctx.role,
    task,
    transcript: transcript.join('\n'),
  })

  return { transcript: transcript.join('\n'), result: transcript[transcript.length - 1] ?? '' }
}

5. The admin schema

Five tables. admin_roles lists the team. audit_log records every write. internal_notes stores the notes the team attaches to entities. saved_searches lets the team save the filter sets they reuse. agent_transcripts stores every agent run. RLS policies on every table; only the right roles see the right rows.

-- supabase/schema/admin.sql
create table if not exists admin_roles (
  user_id uuid primary key references auth.users(id) on delete cascade,
  role text not null check (role in ('admin', 'ops', 'support', 'finance', 'read_only')),
  created_at timestamptz default now()
);

create table if not exists audit_log (
  id uuid primary key default gen_random_uuid(),
  actor_user_id uuid references admin_roles(user_id),
  actor_role text not null,
  entity_type text not null,
  entity_id text not null,
  action text not null,
  before jsonb,
  after jsonb,
  justification text,
  created_at timestamptz default now()
);
create index audit_log_entity_idx on audit_log (entity_type, entity_id, created_at desc);

create table if not exists internal_notes (
  id uuid primary key default gen_random_uuid(),
  entity_type text not null,
  entity_id text not null,
  author_user_id uuid references admin_roles(user_id),
  body text not null,
  created_at timestamptz default now()
);

create table if not exists saved_searches (
  id uuid primary key default gen_random_uuid(),
  owner_user_id uuid references admin_roles(user_id),
  name text not null,
  entity_type text not null,
  filters jsonb not null,
  created_at timestamptz default now()
);

create table if not exists agent_transcripts (
  id uuid primary key default gen_random_uuid(),
  actor_user_id uuid references admin_roles(user_id),
  actor_role text not null,
  task text not null,
  transcript text not null,
  created_at timestamptz default now()
);

alter table admin_roles enable row level security;
alter table audit_log enable row level security;
alter table internal_notes enable row level security;
alter table saved_searches enable row level security;
alter table agent_transcripts enable row level security;

create policy "admin_roles read self" on admin_roles for select
  using (auth.uid() = user_id or current_setting('request.jwt.claim.role') = 'admin');

create policy "audit_log read by role" on audit_log for select
  using (current_setting('request.jwt.claim.role') in ('admin', 'finance'));

6. What this composes

The SSO middleware keeps the wrong people out. The role helper keeps the right people inside their lane. The bulk runner makes the team fast and the changes traceable. The agent does the work the team would otherwise hand to an intern, with a transcript the team can read. The schema makes the whole thing live in one place that does not leak into the product.

The ops team stops being a human ETL pipeline. The spreadsheet that mirrored the customer table is gone. The Friday CSV reconciliation runs from the admin in 12 seconds. The Slack threads that ended with please update these IDs become forms with a confirm dialog and an audit row. The engineer who used to write a script every week works on the product again. The team that grew into a manual-labor company shrinks back into a software company.

Frequently asked questions

How is this different from Retool or Forest Admin?

Retool and Forest are great when the team can fit its workflows into a generic CRUD UI; many can. We build a custom admin when the team has workflows that span multiple entities, when AI agents would help, when audit and compliance need to be tight, or when the admin will outlive a Retool license. We have shipped both kinds; the scoping call decides which one fits.

Does the admin tool replace our existing internal tools?

Partially, by design. The first build replaces the highest-pain spreadsheets and the most repeated manual workflows. Linear, Slack, Notion stay where the team likes them. The admin tool is the place the team writes back to the product, not a project management tool that competes with what the team already uses.

How do we keep this from becoming the next mess?

Three things. The workflow audit is the spec; we do not add a screen without a workflow row in the doc. The runbook explains how to remove screens the team stops using. Quarterly we re-run the audit and prune; screens that no role uses get archived. The admin is a living tool, not a graveyard of half-finished features.

What about access for contractors and external partners?

Same SSO, with the contractor invited to a role with a narrow scope and a 90-day expiration. The role lives in the permission config; the expiration is enforced by a daily cron. External partners get the same flow; the engagement leaves the team with the pattern for handling both.

How long does it take?

Six to ten weeks for the first cutover. Workflow audit takes one week. Schema and SSO take one week. The shell with the top three workflows ships in week three. The remaining nine workflows ship across weeks four through seven. Audit log, bulk actions, and CSV ship in parallel. Agent integration takes two weeks if it is in scope; many teams add it in a follow-up engagement after they have lived with the tool for a month.

Do you build the dashboards too, or just the workflows?

Both. The same schema that powers the workflows powers the dashboard. We build the four to eight numbers the founder asks about every Monday in the first build; we add more on request. Dashboards that nobody opens get archived in the quarterly review.

What about agents that act on customer data?

An agent that touches customer data runs under the same RLS as a human in its role. It cannot access more than the role allows. Every action goes through the audit log with the agent named as the actor. The team can pause every agent with one toggle; the toggle is in the runbook.

Do you replace our operations engineer?

No. The admin tool makes the operations engineer's work durable. Instead of writing a script every week, the engineer adds a screen or a bulk action; the workflow becomes shared infrastructure. Operations engineers usually find the engagement freeing because the ad-hoc work compresses into a smaller, more interesting surface.

Scope your internal admin build

A scoping call, a workflow audit in the first week, a fixed scope and a number we hold. Six to ten weeks from kickoff to an admin tool the team uses every day.