Skip to main content
You don’t call these yourself — your AI client does, based on what you ask it in plain language. This page is here for when you want to know exactly what happened, ask for something specific, or debug a client that picked the wrong tool. The connector exposes 10 tools by default, and up to 18 depending on which scopes you granted when you signed in.

Conventions

Every tool returns pretty-printed JSON. A few patterns repeat:
  • ok: false is 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 a status call, 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.
If your client can’t find a dataset_* tool, that’s the scope, not a bug. import deliberately stays on the default scope, so building an app from a spreadsheet works without the datasets box.

Which app a tool acts on

Most tools take an optional projectId. Where it comes from, in order of authority:
  1. .gaia/project.json in 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.
  2. The tool that created or selected the app, earlier in the same conversation.
  3. apps_list, when you have neither.
The “currently active” app is remembered per account, not per conversation. On a client the connector can’t isolate — one that reconnects before every call, like Cowork — it refuses to guess and asks you to name the app instead. An active app also goes stale after 30 minutes of no use.

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.
The starter teaches one habit worth knowing yourself: name Gaia in the request. An assistant with several tools connected has nothing else to go on — “build me an app to track expenses” is just as likely to be answered out of its own head as routed to the connector. “Using Gaia, build me…” is what makes it land.

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.
A file that won’t fit one road means take the next road. It never means shrink, split, or convert the file. Sending a “cleaned up” workbook doesn’t import a simpler app — it imports a different and wrong one.
After 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.
“Build an app from this” always means a new app — even from the same spreadsheet you used an hour ago. Same data is not the same app, and silently attaching new data to an existing one is rarely what anyone wants. Only target an existing app when you name it: “add this to the Summit tracker”.

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.
Never re-send the same chat message. The turn keeps running server-side whatever happens to the connection, so a second call isn’t a retry: it runs the planner again and stacks a duplicate plan card someone has to clear by hand.

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.
Reach for this whenever you’re about to send someone a link. The failure worth avoiding is mailing a teammate a URL that 404s for them.

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.
The two axes land at different times. accessMode is enforced on every launch, so it takes effect the moment it’s saved. externalAccess, requireEmailVerification and domainWhitelist become part of the app when it’s published — a published app keeps its old login and signup pages until you republish it.The result says which: requiresRepublish: true means the live app still behaves the old way.
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.
A 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.
Everyone on the Gainable account, as opposed to on one app — the Users page in settings. This is where somebody has to exist before 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.
Inviting sends email to a third party and can spend money. action:"invite" mails an invitation link that expires in 14 days; they are not on the account until they accept it. The right word in a report is invited, not added.list comes back with builderSeatsUsed, builderSeatLimit and builderSeatsFree, so a client can tell you a promotion will fail before it tries.

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:
Every sync replaces everything. Rows absent from the payload are deleted. Send the complete set every time — if a collector failed halfway, abort rather than syncing what it managed to collect.
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.
Seed a create with 5–20 real rows. The analyzer infers column types and picks the primary key from actual values, and that choice is frozen for the dataset’s life — a one-row seed produces a schema you cannot fix later.

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 the code 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.
Only ask for author mode when you actually want it. Ordinary requests — “add KPIs to the sponsors view”, “redesign the deals page”, “fix the kanban bug” — belong to chat, which is one cheap turn and does them well. Author mode reads conventions, reads files, and runs validators. It’s the exception, not the upgrade.

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:
  1. Only rw files are writableviews/, routes/, db/models/, public/css/, public/js/. Everything else is framework-owned and push rejects it.
  2. server.js belongs to the registrar. Write the view and the route; push wires them up.
  3. 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.
  4. Treat validator output as binding. Those ~100 rules each encode a production incident someone already had. A rejection is the rule being right.
  5. Pass baseHash when editing. A stale rejection 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.