Building the AllSquare CLI with Effect
TL;DR: I built and published allsquare, a command-line client for AllSquare that works for people and for coding agents. It's written with Effect, which gave me typed errors, swappable services, and guaranteed cleanup for the parts of a CLI that are harder than they look: OAuth, token refresh across processes, safe retries, and confirmation before anything changes.
The AllSquare connector lets ChatGPT, Claude, and other MCP clients use AllSquare. That covers people who live in a chat window.
A lot of agents live in a terminal instead. Claude Code, Codex, and Gemini CLI can all run shell commands, and a shell command doesn't need a client that speaks MCP. It just needs to be installed.
So I built a CLI.
npm install -g allsquare
allsquare auth login
allsquare balances listIt talks to the public /api/v1 API, which runs on the same connector actions as ChatGPT and Claude. Expenses created from the terminal go through the same split validation, currency rules, and settlement confirmation as everywhere else.
The command surface is simple. Groups, balances, expenses, recurring expenses, settlements, and spend summaries. The work was in everything around those commands.
Why Effect
A CLI looks like a thin wrapper around HTTP requests. This one has to:
- register itself as an OAuth client and sign in with PKCE
- run a temporary loopback server to receive the OAuth callback
- store refresh tokens in the operating system's keychain
- refresh tokens safely when several processes run at once
- make writes an agent can retry without duplicating anything
- ask for confirmation before deleting or settling
- update itself without breaking the installation
Each of those can fail in its own way, and an agent needs to know which way.
In the web app I use better-result to keep expected failures typed. That covers errors, but the CLI needed more: dependency injection so I could test auth without a real browser or keychain, resource management for locks and servers, schemas for every API response, and an argument parser.
Effect covers all of that in one library. Commands, flags, HTTP, schemas, config, file system, and crypto all come from the same place and compose the same way. The CLI has four runtime dependencies: three Effect packages and a binding for the OS keychain.
It also turns out agents are very good at writing Effect.
Every effect carries what it returns, how it can fail, and which services it needs in its type. When an agent forgets to handle an error or provide a dependency, the compiler says so immediately, and the agent fixes it before I ever see it. The patterns repeat everywhere, too: a service, a layer, a tagged error. Once a few good examples exist in the codebase, an agent follows them.
That takes most of the sting out of the usual objection. Effect's learning curve is real, but agents absorb a lot of it. I spent my time deciding how the CLI should behave and reviewing code whose types already spelled out what could go wrong.
Errors Are the Interface
In the connector, I treated tool descriptions as UX copy for a reader that happens to be a model. In a CLI, most of that job falls to errors.
A person reads the message. An agent reads the exit code and decides what to do next: retry, ask the user to log in, fix an argument, or stop.
Every expected failure is a tagged error class, and each one carries its own exit code:
export class AuthRequiredError extends Schema.TaggedError<AuthRequiredError>()(
"AuthRequiredError",
{
message: Schema.String,
reason: Schema.optionalKey(Schema.Literal("insufficient_scope"))
}
) {
readonly [Runtime.errorExitCode] = 3
}
export class ApiRequestError extends Schema.TaggedError<ApiRequestError>()(
"ApiRequestError",
{
message: Schema.String,
status: Schema.optionalKey(Schema.Number),
apiCode: Schema.optionalKey(Schema.String),
retryAfterSeconds: Schema.optionalKey(Schema.Number)
}
) {
get [Runtime.errorExitCode]() {
if (this.status === 401 || this.status === 403) return 3
if (this.status === 409) return this.retryAfterSeconds === undefined ? 4 : 5
if (this.status === 404) return 6
if (this.status === 400 || this.status === 422) return 2
return 5
}
}Because they're tagged, TypeScript knows exactly which errors each command can produce, and Effect.catchTag can handle one kind without swallowing the rest. The exit codes stay small and stable:
- 2 invalid input or configuration
- 3 authentication or permission
- 4 conflict
- 5 temporary or service failure
- 6 not found
With --json, the same error becomes an envelope on stderr. Every error includes a retryable flag, which is true only when running the exact same command again can succeed.
{
"ok": false,
"error": {
"code": "AUTH_REQUIRED",
"message": "This session is read-only. Run `allsquare auth login` to grant write access.",
"retryable": false
}
}That flag took more care than I expected. A 409 can mean the same write is still running on the server, which is worth retrying, or that the expense was already settled, which isn't. The server sends a retry hint only for the first case, and the CLI uses that hint to decide.
For an agent, the error is the next instruction.
Services and Layers
Each piece of the CLI is an Effect service: config, the credential store, the installation store, the OAuth client, the callback server, the browser launcher, auth, the API client, and the updater.
Services declare what they need. AuthService asks for the credential store, the OAuth client, and the rest, and doesn't know how any of them are built. The whole application gets wired together in one file:
const infrastructure = Layer.mergeAll(
NodeServices.layer,
CliConfig.layer,
FetchHttpClient.layer
)
const authDependencies = Layer.mergeAll(
BrowserLauncher.layer,
AuthorizationCallback.layer,
CredentialStore.layer,
InstallationStore.layer,
OAuthClient.layer
).pipe(Layer.provideMerge(infrastructure))
const auth = AuthService.layer.pipe(
Layer.provideMerge(authDependencies)
)
export const AppLayer = Layer.mergeAll(
ApiClient.layer,
UpdateService.layer
).pipe(Layer.provideMerge(auth))If I forget to provide something, it's a type error, not a crash the first time someone runs auth login.
Tests replace the pieces that touch the outside world. The auth tests never open a browser, start a server, or read the real keychain:
const services = Layer.mergeAll(
NodeServices.layer,
Layer.succeed(
CliConfig,
CliConfig.of({
apiUrl: Effect.succeed(resource),
configDirectory: Effect.succeed("/unused")
})
),
Layer.succeed(
BrowserLauncher,
BrowserLauncher.of({ open: () => Effect.void })
),
// fake callback, credential store, installation store, OAuth client...
)That made it practical to test the cases that are hard to reproduce by hand: a refresh token that was rotated by another process, a revocation that fails during logout, registration metadata that won't parse. The CLI has 157 tests.
More Than One Process
The part I spent the most time on was token refresh.
The OAuth server rotates refresh tokens. Each refresh returns a new one and retires the old one. If a retired token ever shows up again, the server treats it as stolen and revokes that client's whole session.
That's the right behavior for security, and it's a trap for a CLI. Agents run commands in parallel. If two processes notice the access token is about to expire and both refresh with the same token, the second one looks exactly like theft, and the user is signed out.
So refresh runs under a lock that works across processes. After a process gets the lock, it reloads the credentials before deciding anything, because another process may have refreshed while it was waiting:
const refreshCredentials = (apiUrl: string, force: boolean) =>
installations.withAuthLock(
Effect.gen(function*() {
// Always reload after acquiring the cross-process lock. Another CLI
// process may have rotated and saved the refresh token while this
// process was waiting.
const current = yield* credentialStore.load(apiUrl)
if (!current) {
return yield* new AuthRequiredError({
message: "You are not logged in. Run `allsquare auth login`."
})
}
if (!force && current.expiresAt > Date.now() + 60_000) return current
const token = yield* oauth.refresh({
endpoints,
clientId: installation.clientId,
refreshToken: current.refreshToken
})
const next = credentialsFromToken(token)
yield* credentialStore.save(apiUrl, next)
return next
})
)The lock itself is a scoped resource. Effect.acquireRelease guarantees the release runs whether the work succeeds, fails, or gets interrupted by Ctrl-C:
withAuthLock: (effect) =>
Effect.scoped(
Effect.gen(function*() {
const resolved = yield* ensureDirectory
const owner = yield* crypto.randomUUIDv4
yield* Effect.acquireRelease(
acquireLock(resolved.lock, owner),
() => releaseLock(resolved.lock, owner)
)
return yield* effect
})
)Registration, login, and logout use the same lock.
Writes an Agent Can Retry
Agents lose responses. A network call times out, a process gets killed, a context window gets trimmed. The agent doesn't know if the expense was created, so it tries again.
Every write sends an idempotency key. The CLI generates one per command, and --idempotency-key lets an agent reuse its own key across separate runs. If the server already finished that request, it returns the original result and the CLI adds "replayed": true so nobody mistakes it for a second write.
A retry has to be the identical request, and that exposed some small bugs. --date defaults to today, so a retry after midnight sent a different date and got rejected as a new request. Keyed creates now require an explicit date.
Settlement was the hardest case. Like the connector, the CLI previews a settlement before recording it, and interactive use asks for confirmation with the default set to “no.” Agents pass --yes, which means the agent already got the user's confirmation. It doesn't grant permission on its own.
But if the first settle succeeded and the response was lost, the retry's preview fails: those expenses are already settled. The CLI has to recognize that and send the settle request anyway, so the server can replay the original:
const previewResult = yield* Effect.result(
api.post({
path: `/groups/${groupId}/settlements/preview`,
scope: API_READ_SCOPE,
body: { expenseIds, currency },
bodySchema: PreviewSettlementRequest,
schema: PreviewSettlementResponse
})
)
// A retry after a lost response finds the expenses already settled, so
// the preview conflicts. With the original idempotency key, send the settle
// request anyway: the server returns the original settlement for that key,
// or rejects the request if these expenses were settled some other way.
const retryingSettledRequest = previewResult._tag === "Failure" &&
suppliedKey !== undefined &&
previewResult.failure._tag === "ApiRequestError" &&
previewResult.failure.status === 409Effect.result turns the preview into a value I can inspect instead of an error that ends the command. Deletes work the same way: a retry with the original key returns the original deletion even though the item is already gone.
Keeping the CLI Honest
The CLI is a standalone package. It doesn't import anything from the AllSquare app, so it can be installed without the app's dependencies.
That means it has its own copy of every request and response shape, written as Effect schemas, while the server defines them with Zod. Two copies of a contract will drift.
// Keep the CLI standalone at runtime. The repository contract test compares
// these schemas with the generated v1 OpenAPI document before release.
export const cliApiContract = {
listGroups: { response: Model.ListGroupsResponse },
createExpense: {
request: Model.CreateExpenseRequest,
response: Model.CreateExpenseResponse
},
previewSettlement: {
request: Model.PreviewSettlementRequest,
response: Model.PreviewSettlementResponse
},
settleExpenses: {
request: Model.SettleExpensesRequest,
response: Model.SettleExpensesResponse
},
// ...every other operation the CLI calls
} as constBefore every release, a test in the app repository converts both sets of schemas to JSON Schema, normalizes the differences between the two generators, and compares the wire shapes. If the server adds a required field or changes a type, the CLI can't be packaged until it catches up.
Agents also get their own entry point. allsquare agent-guide --json returns a versioned operating contract: how to discover IDs, when to preview, how to retry, what every error code means, and what never to claim, like that AllSquare completed a Venmo payment.
What Went Wrong
The first version I published crashed on startup.
I built it on Effect 4 while it was still a release candidate. My lockfile kept every Effect package on the same version, but a fresh install from npm pulled in a newer one through a caret range, and allsquare failed before printing anything.
I pinned it, published 0.1.1, and added a release check that installs the packed tarball the way a user would, with no lockfile, and runs it. Effect 4.0 has since shipped as stable, and moving the CLI onto it was mostly a matter of import paths.
What Shipped
allsquare is on npm as a beta. It supports sign-in with read-only or read and write access, the full set of group, expense, recurring expense, balance, settlement, and spend summary commands, stable JSON output, safe retries, and guarded self-updates.
Like the connector, it isn't a second version of AllSquare. It's another way in, with its own interface and its own failure modes, built on the same product rules as everything else.