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
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.
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.
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.
Aa
300 · 400 · 500 · 600 · 700
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.
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.
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.
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.
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
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
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.
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.
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-xyz/ts-sdk. One singleton client, server-only. Every customers/wallets/recipients/transactions call goes through it. Sandbox + production share the same call shape.
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.
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
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
// 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;
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
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
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.
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.
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.
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.
Every snippet below is verbatim from the running app, lightly simplified for clarity.
// 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;
}
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.
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).
// 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"],
});
// 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",
});
}
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.
The API surface stays the same. The differences below are the four spots where the sandbox shortcut needs to come out.
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.xyzSandbox 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.xyzIn 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.xyzSame 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.xyzDocs
The full SDK reference, webhook event catalog, and KYB application flow live on docs.dakota.xyz.