Challenge details · for judges, developers, the curious

How we built IndieBank.

Three sections, top to bottom: the design choices we made and why, the architecture under the UI, and the exact Dakota SDK surface that moves real money.

Section 1 · The design

One lime. One paper. One typeface.

IndieBank is a banking app for people who run solo — founders, freelancers, one-person companies. The design had to feel like a tool a designer would have made for themselves — opinionated, confident, light, with no panel competing for attention. So we picked a single accent, a single surface material, and a single typeface, and built the whole product on top of those three constraints.

Accent#D7FE03
$D7FE03

Bright lime, used like a highlighter

One primary CTA per surface, the active nav item, the in-flight stat tile, the approved verdict. The accent is rare enough that when it appears, your eye snaps to it. Mandatory pairing: black text on accent fills.

Surface.glass
Liquid paper

Light glass — "liquid paper"

Every elevated surface uses one recipe: 72% white, 24px backdrop blur, a 1px white inner highlight, a layered drop shadow. Reads as a frosted paper card hovering over the canvas. Two large lime "lava" blobs drift behind so the glass has something to refract.

TypefaceUrbanist

Aa

300 · 400 · 500 · 600 · 700

Urbanist — geometric warmth

Self-hosted via next/font. Geometric enough to feel modern, round enough to feel friendly. Headlines run 500–600 with tight tracking; body sits at 13–13.5px with a 1.5 line-height so dense screens stay readable.

Generous radii, generous whitespace

Card corners run 24–28px. Pills are 999. Tables breathe at ~12px row height. The whole UI feels closer to a notebook than a spreadsheet — which is the point, because the user is a person, not a finance team.

Inverted sidebar for contrast

The app shell uses a soft charcoal glass sidebar against the light canvas. Active nav still uses the lime fill — so the accent ties the two surfaces together without flooding either one.

Apple-spring motion

Buttons spring at cubic-bezier(0.34, 1.56, 0.64, 1). Cards lift -translate-y-[1px] on hover. Drawers slide in from the right at 320ms with the Apple ease. All motion respects prefers-reduced-motion.

One drawer pattern for everything

AI review, "Post a job", "Add worker" all share the same right-side glass drawer shape. Header with an accent icon puck, body in eyebrow-labeled sections, sticky footer with a ghost "Cancel" and an accent CTA. Add a new drawer once you know this shape and the muscle memory is already there.

Section 2 · Architecture

A Next.js server. Three external rails. One AI agent.

The whole product lives in a single Next.js 16 app. Server components and route handlers do the heavy work; client components handle drawers, charts, and animations. Three external services sit behind the server — Dakota for money, OpenAI for AI review, Supabase for auth + DB + file storage.

System architecture

Three actors, one server, three external rails.

Internal trafficExternal service
OperatorBrowser
FreelancerBrowser
IndieBankNext.js · server
DakotaMoney rails
OpenAIVercel AI SDK
SupabaseAuth · DB · Storage

Next.js 16 · App Router

Server components by default. Route handlers under app/api/*. Server actions are kept light — the Dakota SDK only runs server-side, never in the browser.

Supabase

Auth (email + password), Postgres with RLS, Storage for file uploads. Trigger handle_new_user inserts a profile row with role + hourly_rate from signup metadata.

OpenAI · gpt-4o-mini

Wired through the Vercel AI SDK. generateObject + a Zod schema returns a typed verdict. Configurable via OPENAI_MODEL — swap to gpt-4o for richer reviews on long docs.

Dakota TS SDK

@dakota-xyz/ts-sdk. One singleton client, server-only. Every customers/wallets/recipients/transactions call goes through it. Sandbox + production share the same call shape.

Tailwind 4 · Base UI

All tokens live in globals.css. Base UI primitives for Dialog/Select/Dropdown/Tabs/Tooltip. shadcn-like wrapper components in components/ui to keep the API ergonomic.

Sonner for toasts

Glass-styled toasts via a custom theme. Success, warning, error, loading all pull from the same token palette as the rest of the UI.

Feature spotlight

The freelance jobs feature — agent-in-the-loop payments.

This is the one differentiator that takes IndieBank past "another neobank." Operators post a brief, freelancers submit work + hours, and the AI agent reads the brief and the submitted artifact and returns a structured verdict (decision + per-requirement checklist + actionable feedback) before a single cent moves.

Freelance jobs lifecycle

Post, review, ship — with an AI agent in the loop.

Post jobOperator
Submit workFreelancer
AI reviewVercel AI · OpenAI
RevisionsFreelancer iterates
ApprovedVerdict + checklist
PayUSDC → USD ACH
Happy pathRevisions loop
lib/agents/review-job.ts
typescript
// lib/agents/review-job.ts
// Pulls the submitted artifact, asks OpenAI for a structured verdict.

const result = await generateObject({
  model: openai(process.env.OPENAI_MODEL ?? "gpt-4o-mini"),
  schema: ReviewSchema,                   // Zod schema, .nullable() not .optional()
  system: STRICT_SPEC_REVIEWER_PROMPT,
  messages: [{
    role: "user",
    content: [
      { type: "text", text: brief + submission },
      // If the deliverable is an image, pass it as a vision input.
      // If text/markdown/code, inline the body (capped at ~120KB).
      ...(file ? [{ type: "image", image: new URL(file.url) }] : []),
    ],
  }],
});

// result.object is { decision, feedback, checklist[] } — typed by Zod.
return result.object;
AI review · OpenAI

We use the Vercel AI SDK's generateObject with a Zod schema — the model is forced to return a typed verdict, never free text. Multimodal: text submissions (markdown, code, links) get inlined; image deliverables are passed as vision inputs.

Trap we hit early: OpenAI's strict structured output rejects .optional() fields in Zod. We use .nullable() everywhere instead.

Status state is never persisted server-side as reviewing — that's a client-only animation in the drawer. If the AI call fails, the job stays at its previous status and the operator can retry from the UI.

Section 3 · Dakota usage

How money actually moves.

Dakota's regulated banking + on-chain settlement is the money-movement layer. IndieBank is the indie-operator console on top. Below: the live flow, who needs KYB, the exact SDK calls, and what changes when DAKOTA_ENV=production.

Money flow

USD comes in. USDC moves it. USD lands again.

USDUSDC
Hover a node to inspect that step. Click to pin it.

Who actually needs KYB?

KYB is per-customer in Dakota's model — and only the entity that holds and moves money counts as a customer. Workers and freelancers don't have Dakota customer records, so they don't go through KYB.

Operator

Yes — KYB required.

The operator is the legal entity that holds and moves money. Dakota requires a verified customer record before you can be issued a USD account or hold a wallet. In the sandbox we approve KYB with a single API call; in production it's a hosted form your user fills in.

  • Creates a Dakota customer (business)
  • Provisions a treasury wallet on Ethereum
  • Provisions a USD on-ramp account
Worker / contractor

No KYB. Ever.

A worker is a recipient on the operator's customer — a bank destination, not a balance. They don't hold money, don't sign in to Dakota, don't have an identity record on the platform. They're just a place you send money to.

  • Recipient + fiat_us destination + off-ramp
  • All three created on the operator's customer
  • Add one and it's payable instantly
Freelancer (IndieBank user)

Logs in, but has no Dakota customer.

Freelancers sign in to IndieBank to submit work and see their earnings. They don't get a Dakota customer record — when an operator pays them, we lazily provision a recipient on the operator's customer using their saved bank info.

  • Supabase auth only — IndieBank-side
  • Bank info stored on profile
  • Recipient is created on first pay

The SDK calls that make it move.

Every snippet below is verbatim from the running app, lightly simplified for clarity.

onboarding/route.ts
typescript
// app/api/onboarding/route.ts
// 1) Create the operator's Dakota customer (idempotent).
const created = await dakota.customers.create({
  name: displayName,
  customer_type: "business",
  external_id: `indiebank_${user.id}`,
});
const customerId = created.id;
const applicationId = created.application_id;

// 2) Approve KYB in the sandbox.
//    In PRODUCTION you'd redirect the operator to `application.application_url`
//    and wait for the `customer.kyb_status.updated` webhook instead.
await dakota.sandbox.simulateOnboarding({
  type: "kyb_approve",
  applicant_id: applicationId,
  simulation_id: `sim_kyb_${user.id}`,
});

// 3) Poll until KYB lands as active (sandbox flips in ~1s).
for (let i = 0; i < 8; i++) {
  await sleep(300);
  const c = await dakota.customers.get(customerId);
  if (c.kyb_status === "active") break;
}
Operator KYB

We hit customers.create to mint a Dakota customer, then immediately approve KYB with sandbox.simulateOnboarding — the sandbox shortcut that maps to the real KYB form in production.

After KYB lands as active, we provision a wallet (signer → signer group → wallet) and an on-ramp account whose crypto destination points at the treasury wallet — all in the same request.

No KYB for workers

Adding a worker is three plain create-calls — no identity verification, no application flow. The operator types the worker's bank info into the right-side drawer, and we attach all of it to the operator's customer.

The off-ramp is what actually makes the worker payable: it binds the source asset (USDC on Ethereum Sepolia) to the destination (their US bank via ACH).

workers/route.ts
typescript
// app/api/workers/route.ts — no KYB call here.
// A worker is just a recipient + bank destination + off-ramp on the
// OPERATOR's customer. They never become a Dakota customer themselves.

const recipient = await dakota.recipients.create(profile.dakota_customer_id, {
  name: name.trim(),
  address: { /* mailing address  required by Dakota */ },
});

const destination = await dakota.destinations.create(recipient.id, {
  destination_type: "fiat_us",
  name: `${name.trim()} Bank`,
  bank_name: bankName,
  account_holder_name: name.trim().substring(0, 22),
  account_number: accountNumber,
  aba_routing_number: routingNumber,
  account_type: accountType,
});

const offramp = await dakota.accounts.create({
  account_type: "offramp",
  fiat_destination_id: destination.destination_id,
  source_asset: "USDC",
  source_network_id: "ethereum-sepolia",
  destination_asset: "USD",
  rail: "ach",
  capabilities: ["ach"],
});
workers/[id]/pay/route.ts
typescript
// app/api/workers/[id]/pay/route.ts
// Create the USDC → USD ACH one-off transaction.
const tx = await dakota.transactions.create({
  customer_id: profile.dakota_customer_id,
  source_asset: "USDC",
  source_network_id: "ethereum-sepolia",
  destination_id: destinationId,
  destination_asset: "USD",
  destination_payment_rail: "ach",
  amount,
  payment_reference: "Salary",
});

// SANDBOX-ONLY: nudge the lifecycle to settled so the demo shows a complete
// flow on screen. In production this is a no-op — the partner bank settles
// ACH on its own timeline (1–3 business days) and Dakota posts a webhook.
if (process.env.DAKOTA_ENV !== "production") {
  await dakota.sandbox.simulateInbound({
    simulation_id: `settle_${tx.id}_${Date.now()}`,
    type: "ach_outbound_settled",
    one_off_transaction_id: tx.id,
    amount,
    currency: "USD",
  });
}
Outbound + auto-settle

One transactions.create call kicks the USDC → USD ACH lifecycle. Sandbox auto-settle fires ach_outbound_settled so the demo lifecycle completes on screen.

This is the single place we read process.env.DAKOTA_ENV — every other API call is identical between sandbox and production.

What changes in production.

The API surface stays the same. The differences below are the four spots where the sandbox shortcut needs to come out.

KYB is a real hosted form

Instead of one simulate call, Dakota returns an application_url that you redirect the operator to. They fill in real business details. Status flips when the webhook fires.

Onboarding & KYB on docs.dakota.xyz

Wallet payouts need a signed key

Sandbox uses the SDK helpers, but production payouts move USDC out of your wallet — they must be signed by the signer key associated with the wallet's signer group. You hold that key in a KMS or HSM.

Wallets & signers on docs.dakota.xyz

Settlement is webhook-driven

In sandbox we fire ach_outbound_settled ourselves so the demo shows a complete lifecycle. In production, Dakota's partner bank settles ACH in 1–3 business days and posts a webhook when it lands.

Webhooks on docs.dakota.xyz

Compliance policies enforce in-line

Same code path. Same API. Sandbox and production share the policy engine — every payout is checked against the rules you've set before Dakota submits it to the rail.

Compliance policies on docs.dakota.xyz

Docs

The full SDK reference, webhook event catalog, and KYB application flow live on docs.dakota.xyz.

docs.dakota.xyz

Try it yourself

Open the bank.
Walk the flow end-to-end.