Skip to main content

Building a way for agents to use AllSquare

TL;DR: I designed, built, and shipped an OAuth-backed connector so people can use AllSquare from ChatGPT, Claude, and other AI clients. ChatGPT has an official Custom GPT, and other clients connect over MCP. Agents can check balances, create expenses, and settle up through the same product rules as the browser, with consent, scoped permissions, and a confirmation step before anything is marked settled.

I wanted AllSquare to work from the places people are already using agents.

Someone should be able to ask ChatGPT or Claude:

“What do I owe in my Apartment group?”

Or:

“Add $42.10 for groceries yesterday, split with Alex.”

And have it work the same way it would inside the app.

That meant giving agents access to real product actions: reading groups and balances, creating expenses, setting up recurring expenses, and settling up.

If Claude can change a financial record shared with other people, AllSquare needs to know who it’s acting for, what it has permission to do, and when the user needs to be involved.

I split the connector into two parts:

OAuth handles access. Who is connecting? Which user are they acting for? What did the user approve? Is that access still valid?

The connector handles actions. What can the client ask AllSquare to do, and what rules apply when it does?

ChatGPT also has an official AllSquare Custom GPT. That path uses GPT Actions instead of MCP, but it still goes through the same OAuth flow and the same connector actions.

MCP clients connect through OAuth to the AllSquare MCP connector

Using the Functions That Already Exist

I didn't want to build a separate version of AllSquare for agents.

The browser and the connector both use the same shared ledger modules: the domain functions for expenses, recurring expenses, settlements, permissions, validation, and read models.

An expense created from the AllSquare GPT or from Claude goes through the same group rules, split validation, currency handling, and error paths as one created in the browser.

I use better-result for those flows so expected failures stay explicit and typed instead of getting flattened into exceptions.

A simplified version of that expense flow looks like this:

export async function createExpenseResult(
  input: CreateExpenseInput,
): Promise<
  Result<
    ExpenseRow,
    ValidationError | NotFoundError | ConflictError | UpstreamError
  >
> {
  return Result.gen(async function* () {
    yield* Result.await(assertGroupNotArchived(input.supabase, input.groupId));

    const amountInCents = yield* amountToCents(input.amount);
    const exchangeRate = yield* Result.await(
      loadExchangeRate(input.currency),
    );

    // create the expense, build its splits, persist them...

    return Result.ok(expense);
  });
}

The connector doesn't need its own version of any of that. It translates the request and calls the existing ledger module.

Simplified, the connector action is a thin wrapper around the same function:

export async function createExpenseFromAction(
  session: ConnectorSession,
  groupId: string,
  input: AddExpenseInput,
): Promise<
  Result<
    AddExpenseResponse,
    | ValidationError
    | NotFoundError
    | ConflictError
    | UpstreamError
    | ForbiddenError
  >
> {
  return Result.gen(async function* () {
    yield* Result.await(assertGroupAccess(session, groupId));

    const created = yield* Result.await(
      createExpenseResult({
        supabase: createUserScopedClient(session.userId),
        groupId,
        description: input.description,
        amount: input.amount,
        paidById: input.paidById ?? session.userId,
        splitWith: input.splitWith,
        splitType: input.splitType,
        date: input.date,
        currency: input.currency,
        percentageSplits: input.percentageSplits,
        exactSplits: input.exactSplits,
      }),
    );

    return Result.ok({
      expense: {
        id: String(created.id),
        description: created.description,
        amount: Number(created.amount) / 100,
        groupId,
      },
    });
  });
}

That keeps the important behavior in one place. If I change an expense rule later, I don't have to remember to update an AI-specific version of it.

Designing the Tools

MCP tools are an interface.

A normal UI can communicate through layout and controls. A button can be disabled. Helper text can explain a field. A destructive action can open a confirmation dialog.

An agent doesn't see any of that.

It gets a tool name, a description, a schema, and the result of calling it.

So I treated the tool definitions as product copy rather than API documentation.

Tool descriptions are UX copy for a reader that happens to be a model.

For example, the expense tool explains how different split types work, how to interpret debt phrasing, and what the tool should never be used for.

"Creates an expense in a group. Splits: 'equal' divides among
participants; 'percentage' must sum to 100; 'exact' requires
per-member amounts summing to total. Phrasing like 'I owe Alex
$100' means Alex paid and the current user owes. Use member IDs
from list_group_members, never names. Consequential writes: reuse
the idempotency key when retrying a failed call. Never mark
expenses settled with this tool."

“Never mark expenses settled with this tool” does roughly the same job as removing or disabling a button in the app.

Before creating an expense, the tools also tell the agent to retrieve the group's members and use their real identifiers rather than guessing from names.

The description helps the agent choose the right action. The server still enforces the actual rules.

server.registerTool(
  "create_expense",
  {
    description: `Creates an expense in a group. Splits: 'equal' divides among
participants; 'percentage' must sum to 100; 'exact' requires
per-member amounts summing to total. Phrasing like 'I owe Alex
$100' means Alex paid and the current user owes. Use member IDs
from list_group_members, never names. Consequential writes: reuse
the idempotency key when retrying a failed call. Never mark
expenses settled with this tool.`,
    inputSchema: {
      groupId: z.string(),
      amount: z.number().positive(),
      currency: z.string().length(3),
      description: z.string(),
      date: z.string().optional(),
      paidById: z.string().optional(),
      splitType: z.enum(["equal", "percentage", "exact"]),
      splitWith: z.array(z.string()).min(1),
      percentageSplits: z
        .array(z.object({ userId: z.string(), amount: z.number() }))
        .optional(),
      exactSplits: z
        .array(z.object({ userId: z.string(), amount: z.number() }))
        .optional(),
      idempotencyKey: z.string(),
    },
  },
  async ({ groupId, idempotencyKey, ...input }) =>
    Result.gen(async function* () {
      const stored = yield* Result.await(
        getStoredIdempotencyResult({
          key: idempotencyKey,
          session,
          operation: "add_expense",
          requestBody: { groupId, ...input },
        }),
      );

      if (stored !== null) {
        return Result.ok(stored);
      }

      const created = yield* Result.await(
        createExpenseFromAction(session, groupId, input),
      );

      yield* Result.await(
        storeIdempotencyResult({
          key: idempotencyKey,
          session,
          operation: "add_expense",
          requestBody: { groupId, ...input },
          responseBody: created,
        }),
      );

      return Result.ok(created);
    }),
);

Settlement Gets Its Own Flow

Settling up needed a little more care.

Creating an expense adds to the history. Settlement changes the record to say that money between people has actually been resolved.

I didn't want an agent to jump from “What do I owe?” to marking everything settled without another interaction with the user.

So settlement has two steps.

First, the agent can request a preview. AllSquare returns who owes whom, how much, and an optional Venmo link.

Then the user confirms before the connector can complete it.

function validateSettlementIntent(
  input: SettleExpensesInput,
): Result<void, SettlementConfirmationRequired> {
  if (!input.confirmed) {
    return Result.err(
      new SettlementConfirmationRequired({
        message:
          "Settlement requires explicit confirmation before changing the ledger",
      }),
    );
  }

  return Result.ok(undefined);
}

validateSettlementIntent enforces confirmation again at the mutation boundary. The explicit confirmed flag is the contract: without it, nothing gets settled.

There is still a limit here. If a client says the user confirmed when they didn't, AllSquare can't fully prove otherwise from inside the connector. That needs support from the client or protocol itself.

Connecting a Client

Connecting starts in the AI client and moves to AllSquare in the browser.

The user signs in and sees which client is asking for access and what it wants permission to do.

I kept that to two scopes:

  • allsquare:read
  • allsquare:write

I considered making the scopes more granular, but individual permissions for every tool made the consent screen harder to understand without adding much useful control.

Read and write map pretty closely to what someone actually wants to know:

Can this app see my AllSquare data?

Can this app change anything?

That's enough information to make a useful decision without exposing the internal shape of the connector.

Behind that screen, the connector supports:

  • dynamic client registration
  • PKCE for public clients
  • hashed access and refresh tokens
  • refresh-token rotation
  • refresh-token reuse detection
  • scoped bearer authentication
  • rate limiting
  • idempotency for write retries
  • structured connector logs

Most of that shouldn't be visible to the user. Connecting AllSquare should feel like connecting any other app.

Disconnecting

Connected clients show up in AllSquare Settings with a Disconnect action.

Disconnecting revokes the consent grant, access tokens, and refresh tokens together.

There aren't separate controls for those pieces because there doesn't need to be.

From the user's perspective:

Disconnect the clientit no longer has access.

What Agents Can Do

The connector exposes the parts of AllSquare that make sense through conversation.

Agents can:

  • list groups
  • inspect group members
  • check balances
  • view recent expenses
  • create an expense
  • create a recurring expense
  • preview a settlement
  • complete a confirmed settlement

That covers most of the things I'd naturally want to do from an AI client without trying to reproduce the entire AllSquare UI as tools.

What Shipped

The connector is live as the AllSquare GPT in ChatGPT, and as MCP with Claude, Claude Code, Gemini CLI, and other MCP-compatible clients.

The best part is what I never had to build: a second version of AllSquare.

The connector has its own interface and its own constraints, but underneath it, agents use the same product rules as everyone else.