Conventions
Every tool returns pretty-printed JSON. A few patterns repeat:ok: falseis a report, not a crash. A rejected dataset sync or a drifted payload comes back as a normal result saying nothing was written. Your client should read it, not retry it.outcome: "running"is not a timeout. Long operations hand the wait back rather than risk the call being dropped. The answer is astatuscall, never a second start.- URLs come back as exact strings. They’re also emitted as resource links. Never construct a Gainable URL by hand — an app’s own hostname isn’t reachable without a key the launcher injects.
Scopes
Three groups of tools are absent from the connection entirely unless you ticked their box at sign-in. Not hidden, not refused — not in the list your client can see at all, which is a harder guarantee than a description asking a model not to reach for something.
You can turn any of them on or off later from Account → Connections without reconnecting; the change takes effect on the connection’s next request.
The
users box is only offered to someone who could manage users themselves — the account owner, or a member holding the manage_users permission. Connecting a client can never hand out more than the human behind it already had.Which app a tool acts on
Most tools take an optionalprojectId. Where it comes from, in order of authority:
.gaia/project.jsonin the working folder — written when a tool returns a project anchor, read on the next session. This is what makes a folder remember its app.- The tool that created or selected the app, earlier in the same conversation.
apps_list, when you have neither.
Lifecycle
project_setup
Returns a starter for a new Gainable project: a README.md, a CLAUDE.md for your domain context, data/ and apps/ folders, and the same context condensed into a block you can paste into a project’s Instructions box when there’s no folder to write to.
Your client should offer this once, ask before writing anything, and read your existing context instead if you already have some.
apps_list
Lists the apps in the connected account, most recently used first.
apps_select
Sets which app the following tools act on.
An ambiguous name comes back as a list of matches rather than a guess.
Building a new app
import
Turns an Excel or CSV file into a dataset attached to a project, ready to build from.
Three ways to send the file, tried in this order:
1
upload_url — start here
The connector returns a URL; your client POSTs the file straight to it. No size limit, and the bytes never enter its context.
2
upload_chunk — when that can't connect
Some sandboxes block network egress. The base64 goes up in slices instead: no egress needed, no size limit, but it costs context.
3
fileBase64 — small files only
Simplest, but the whole string passes through your client’s context, so it’s capped around 40 KB.
attach, the build conversation starts with build.
build
Drives the whole new-app journey: a contract conversation through five phases — data → derives → seed → ui → autonomy — and then the deterministic build pipeline.
The harness can’t read files. If you point at a spec document, your client must paste its complete contents into
message. A path or a one-line summary makes the server design from generic domain knowledge instead of your spec.Spreadsheets are the exception — they go through import, never into build as a spec.action:"run" takes 90–180 seconds. Only outcome:"built" means done; anything else means keep watching with status.
Changing a built app
chat
One turn: a server-side planner decides the change and the Build Agent applies it. This is the right tool for almost every edit — adding a field, fixing a view, changing a layout, adjusting behaviour. Asking for a page the app doesn’t have creates it; renaming a page is a display change, so the slug, the URL and the filenames stay put.
If the planner needs a decision it can’t make, the result is
outcome:"needs_clarification" with a question. Answer it as a new chat call — the server recognises the pending clarification and merges it in.
The plan auto-accepts on this path. In the web builder the planner’s plan card waits for you to click Accept; through
chat it applies as soon as it’s written. There is no approval step to wait for and no button to look for.Every turn belongs to a chat
Work done through the connector lands in the same conversation unit the builder screen shows, so you can open the app in Gainable and see it. Calls continue the current chat;newChat:true starts a separate one when you move to an unrelated topic.
The chatId comes back in every result. A client that reconnects between calls — Cowork does — loses its in-memory sense of which conversation it was in, so passing the chatId back is how a turn rejoins the right thread rather than starting a new one each time.
Shipping
publish
Deploys the app to its public https://<slug>.gainable.app URL — the same as the Publish button in the web app. Idempotent.
share
Reports the app’s links and who can actually open each one: the public URL when it’s published, and the in-Gainable launcher link for teammates. It mints nothing — it only tells you what exists.
Who can use an app
share reports the links. These two decide who those links work for — the Users tab of an app in Gainable, reachable from your client.
app_access
Reads or changes the app’s audience. Two independent questions, and the tool answers both in words as well as values.
Narrowing
accessMode to invited revokes access for every account member who isn’t on the app’s list, immediately. The tool says so in its next, but it’s your call to make before asking for it.
app_users
The people list. Two kinds of member, and the difference isn’t cosmetic:
- Internal (
external:false, the default) — someone already on this Gainable account, added to this app. They sign in with their Gainable login and arrive over SSO. - External (
external:true) — a user who exists only inside this app, with their own password and no Gainable account.
Both kinds of
add are gated on the access settings, and the tool enforces the gates rather than describing them:
No invitation email is sent, either way. External users are created without a password and set their first one through Forgot password on the app’s own login page. Internal users simply find the app in their launcher. Tell them yourself.
list in account mode is not authoritative — every active member can open the app whether or not they appear, and rows appear as people first use it. It only becomes the gate once accessMode is invited.
Removing an external user deletes them — that app login was the only one they had. Removing an internal user only revokes their access to that app; their Gainable account is untouched.
People on the account
account_users
Needs the
users scope, and the connected person must be the account owner.app_users can add them to an app, and it’s the refusal app_users gives when they don’t.
Two types of person, and the difference is money and reach:
Separately from the type, a person can hold account capabilities:
manage_users (invite and manage people), api_keys (mint account API keys), connections (manage connected apps), account_views (create shared views in the apps grid). These are orthogonal — either type may hold any of them — and [] is a normal answer, but it has to be an answer rather than an omission. You can’t pass on a capability you don’t hold yourself.
Datasets
All five need the
datasets scope. import does not — that’s the spreadsheet-to-app path, and scoping it would mean a connection without the box ticked couldn’t build an app from a file at all.import bootstraps an app from a spreadsheet. These five work with standalone datasets that a script keeps fresh — create once, sync forever. See Recurring data pipelines for the full workflow.
dataset_list
Find a dataset before syncing, reading or deleting it.
Each row carries the provider, row count, last sync, whether the source is syncable from a payload or only re-fetchable from upstream, and
connectedApps — which apps depend on it. That last one is what you need before proposing to replace or delete anything, so it’s always there rather than behind a flag.
dataset_schema
Returns the write contract: the required keys per sheet, the primary key, and the expected form of every field. Read this before writing any collector.
Each field carries a
writeAs describing exactly what to emit:
dataset_records
Reads the rows — the actual values, not the shape. Before this existed, the only way to see whether a sync landed was to build an app over the dataset.
availableEntities is on every response even when there’s only one, so a multi-entity dataset never gets reported as if its first entity were the whole thing.
Only adapter-backed sources store rows in Gainable. A source whose data stays at the provider comes back as an explicit refusal naming the entity, rather than as an empty page you’d have no way to see through.
dataset_sync
Gets data into a dataset — creating one when there isn’t one yet. This was two tools; which applied depended on whether the dataset already existed, which is a fact about the account rather than about what you meant.
Three transports, picked by what you send:
Syncing never creates. An unmatched name is an error, because the server can’t tell a typo from a new dataset and would silently make a duplicate —
"Deals Pipeline (2)" — while the real one quietly goes stale.Use action:"create" when you want a new dataset every time, or createIfMissing:true for an unattended collector that should seed on its first run and replace on every run after. Not for a one-off sync you’re driving by hand, where a caught typo is the point.dataset_delete
Permanently deletes the dataset, its configuration and every row in it. No undo, no export first.
Two guards, against two different mistakes.
confirm is against deleting the wrong thing on a vague instruction — “get rid of the old pipeline one” resolves to exactly one dataset without anybody having seen which, so the name has to travel through the conversation where you can catch it. And a dataset any app is still using can’t be deleted at all: the refusal names the apps, and they have to be detached first.
Author mode
Two extra tools appear only when you granted thecode scope — the “Also let it write code in your apps” checkbox at sign-in.
This is a scope, not a setting, for two reasons. A token that can build apps shouldn’t automatically be able to author code into them. And a model can’t over-reach for a tool that isn’t in its list, which is a harder guarantee than a description asking it not to.
code_context
Step one: read before you write.
manifest returns the app index — every file with its path, mode, and hash — plus the list of convention guides, with no file contents. Your client then fetches the two or three guides for the area it’s touching and the files it will actually change, rather than everything.
app_file returns the content and a hash. That hash goes back as baseHash when you push.
code_push
Step two: check your work, then commit it.
push validates the whole batch, writes it atomically, registers new view routes, commits to the app’s git history, and records the session in the project’s chat history — which is why summary is mandatory: it’s what the human sees alongside their own refinements.
The rules that make this work:
- Only
rwfiles are writable —views/,routes/,db/models/,public/css/,public/js/. Everything else is framework-owned and push rejects it. server.jsbelongs to the registrar. Write the view and the route; push wires them up.- Push the whole stack of a feature in one call — view, route, and model together. The cross-file validators judge the batch as a whole, so a view whose route is “coming next” is correctly rejected.
- Treat validator output as binding. Those ~100 rules each encode a production incident someone already had. A rejection is the rule being right.
- Pass
baseHashwhen editing. Astalerejection means the file changed server-side since you read it: re-read, re-apply, re-push. Don’t drop the hash to force it through.
When something goes wrong
Next steps
Recurring data pipelines
The full dataset workflow, end to end.
Overview
How the connector fits together.