https://api.elaichi.ai
A machine-readable description is served live, unauthenticated:
GET https://api.elaichi.ai/openapi.json (also /openapi.yaml)
GET https://api.elaichi.ai/schema/openapi.json (also /schema/openapi.yml)
Treat that schema as the contract. Every route the server mounts falls in one of three groups:
| Group | In the schema? | Build on it? |
|---|---|---|
| Published | Yes | Yes. This is the API. |
| Internal — the console's own screens, browser redirects, inbound webhooks, staff tools | No | No. Its URLs can change without notice. |
| Invite-only — automations, approvals, collections, dashboards and public links, knowledge, web access, apps, spend | No, not yet | Only if the organization has the feature, and expect changes. See Endpoints. |
Paths are unversioned — /connection, not /v1/connection. An unknown path
asked for with Accept: text/markdown answers a short Markdown page that
points at the schema, instead of JSON.
Authentication
Authorization: Bearer elch_{org_id}_{64 hex}
A person creates a token at Settings → API tokens in
app.elaichi.ai. It needs api_token:create (the
built-in Member role has it) and a fresh re-verification (passkey, two-factor
code or emailed code) at that moment. The raw value is shown exactly once.
What a token is:
- It has no scopes. It acts as the person who created it, with their live permissions. Change their role and the token changes at once. Remove them and it stops working.
- It is bound to one organization. The org id is inside the token.
- It cannot manage tokens. Every
/api-tokenroute refuses a token with403. Listing, minting, renaming and revoking tokens happen in the app.
Things only a person in the browser can do
Some actions refuse any token with 403 forbidden ("This action needs an
interactive browser session"). The right answer is to tell the person to do it
in the app. Do not look for a way around it.
- Anything under
/api-tokenand/scim-token. - Resolving an access request (
POST /access-request/:id/resolve). - Scheduling the organization's deletion (
DELETE /organization/:id), and changing whether the organization requires two-factor sign-in. - Approving an AI client on the consent screen.
- Changing payment settings (invite-only spend controls).
A token never gets 428 step_up_required. That code goes to a browser session
that must re-verify first. Some routes ask the browser to re-verify but let a
token through: invites, changing a member's role, deleting a role or team,
org domains, group mappings, creating or editing an SSO connection. That is
deliberate — getting a token already cost a re-verification.
There is no supported way to get a browser session outside the browser. Do not try to mint, borrow or replay one.
The organization header
X-Organization-Id: org_…
With an API token the header is optional — the token already names the
organization. If you send it, it must match, or you get 403.
/organization/:id/* routes take the org from the path instead.
An organization without an active plan answers 403 subscription_required on
its product routes. A feature the plan does not include answers
403 feature_not_available, with feature and plan in the error.
The list envelope
Every endpoint returning an array uses the same shape.
{
"result": [ /* … */ ],
"next_cursor": "opaque-or-null",
"prev_cursor": "opaque-or-null"
}
| Parameter | Behavior |
|---|---|
limit |
Default 50, maximum 200. Above 200 is clamped, not rejected. Zero, negative or not a number is a 400. |
cursor |
Opaque. Round-trip it untouched. An empty string means "first page". |
q |
Free-text search, up to 200 characters. Whitespace-only is ignored. |
Page until next_cursor is null. That is the only stop condition. A short
page, or an empty one, is not the last page.
How q matches, on every route that takes it: each word must appear somewhere
in the searched fields, in any order. If that finds nothing at all, a second
pass also accepts one typo in words of five letters or more. So slakc finds
Slack.
Two rules that prevent the most common bugs:
- Never search, filter, sort or count in your code over a paginated list.
You would see only the pages you fetched.
qand filters go to the server, which applies them before paging. Counts come from server fields, neverresult.length. - Changing
qor a filter starts a new result set. Drop the cursor and ask for page one. Some cursors are bound to the query they came from and answer400if replayed under another one.
Most cursors are keyed on id, so they stay stable as rows are added. A few lists the server builds in memory use an offset cursor, which can shift by one row if the set changes between pages.
Errors
{
"error": {
"message": "You need the “Manage teams” permission (team:manage) to manage this team.",
"code": "permission_required",
"details": {
"required_permissions": ["team:manage"],
"action": "manage this team"
}
}
}
| Code | HTTP | Meaning |
|---|---|---|
validation_error |
400 | A field failed validation — including a bad limit, a long q, or a malformed cursor |
bad_request |
400 | Malformed request |
missing_organization_header |
400 | No X-Organization-Id on an org-scoped route (browser sessions only) |
unauthorized |
401 | Missing or invalid credential |
permission_required |
403 | Role denial. details.required_permissions lists every permission that would do — any one is enough |
forbidden |
403 | Not allowed — including "needs an interactive browser session" |
subscription_required |
403 | The organization has no active plan |
feature_not_available |
403 | The plan does not include this feature |
not_found |
404 | No such resource — or one deliberately hidden from you |
conflict |
409 | Clashes with current state. Many routes use a more specific 409 code |
payload_too_large |
413 | Body too big |
step_up_required |
428 | Browser sessions only: re-verify first |
rate_limited |
429 | Over the limit. See Retry-After |
internal_error |
500 | Server failure |
platform_*_stopped |
503 | Elaichi paused sign-ups, new connections or MCP platform-wide |
clove_timeout, saffron_timeout |
504 | An internal service did not answer in time. Nothing changed. Retry |
Two habits:
- Show
error.messageunchanged. It is written for a person and names the permission in their words. Do not rewrite it as "403 Forbidden". - Treat a
404on something you believe exists as "not yours". Elaichi hides resources you hold no grant on rather than confirming they exist.
Every response carries X-Request-Id (req_…). Log it — support will ask for
it.
Capability fields
The server computes what the caller may do and ships it as a field. Read
it. Never rebuild it from owner_user_id and a permission list. A guess is
either too loose (a button that 403s) or too strict (a hidden action the
person was allowed to take), and it cannot see server-only facts.
| Field | Answers |
|---|---|
can_use |
May the caller use it — run a toolbox's tools, pin a connection, stamp a template, connect through a custom connector |
can_share |
May the caller add grantees |
can_manage |
May the caller change this resource's own settings |
can_transfer |
May the caller give it away. Usually also "may delete" — both are owner-only |
can_revoke_share |
May the caller remove an existing grant |
can_see_shares |
May the caller be told who it is shared with |
can_manage is the one name for "may change its settings" on every resource.
There is no can_edit or can_update.
Some resources add fields for questions only they have. Same rule: read them.
Connections carry can_reconnect, can_refresh_credentials and (on the
detail) can_run_post_install. Connectors carry can_delete,
can_configure_oauth_app and can_fork. Roles carry can_grant. Toolbox
entries carry can_repin.
Beside them, not instead of them:
access_viasays how the caller reaches a row —owner,direct,teamororg(broadest wins), withaccess_via_teamfor a team. It is omitted when nothing reaches the caller. Absence means "no source to name", never "not permitted".restricted/restricted_by(role,userornull) say an admin's restriction blocks this for the caller. That is a different gate fromcan_use, with a different fix (an access request).- A
sharesarray being present is not permission to show it. Usecan_see_shares.
Command responses (create, update, transfer, reconnect) carry the same can_*
fields as list rows, so a freshly changed row keeps its actions.
URL conventions
| Kind | Pattern | Examples |
|---|---|---|
| CRUD | GET/POST /resource, GET/PATCH/DELETE /resource/:id |
/role, /sso-connection |
| Action | POST /resource/:id/<verb> |
verify, reconnect, execute, transfer, test |
| Nested collection | /resource/:id/<collection>[/:childId] |
/team/:id/member, /toolbox/:id/share |
- Ordinary fields update through
PATCH /resource/:id. Every field is optional.nullclears a nullable field. There are no attribute subpaths — noPATCH /org-domain/:id/default-role. POST /:id/<verb>is only for things that do something, not for editing a field.- No row embeds an unbounded child list. A row carries a count and a small
preview. The full set has its own paginated, searchable endpoint — a team
row has counts and a preview, the roster is
GET /team/:id/member. - Every
POST /…/sharetakes one grantee or many. Sendgrantees(up to 50) with onelevelto share with several at once. Refused grantees come back inrejected; the rest are granted.
Rate limits
| Surface | Limit | Keyed on |
|---|---|---|
| REST API | 600 requests / 60s | The API token |
| MCP endpoint | 120 requests / 60s | The OAuth access token |
Over the limit: 429, code rate_limited, and Retry-After in seconds.
Honor it.
The published API has no idempotency key. Design writes so a retry after a timeout is safe to reason about — re-read before you repeat a write.
Ids
TypeIDs: {prefix}_{26 characters}. Opaque to you, but sortable by time
within a prefix, which is why cursors use them.
Common prefixes: org, usr, team, role, conn (connection), tbx
(toolbox), tpl (template), syn (synthetic tool), acl (a grant), rstr
(restriction), inv (invite), atok (API token), aud (audit event), areq
(access request), file. Invite-only resources add auto (automation),
coll (collection), dash (dashboard), kbs (knowledge base) and bndl
(app). The full table is in Conventions.
Connectors are named by slug (hubspot), not an id.
Three id-shaped things are not TypeIDs, because they carry an org id:
elch_… (API token), einv_… (invite), escim_… (SCIM token). All three are
{prefix}_{orgId}_{secret}.
The automatic toolboxes have computed ids — global:{userId} (listed as "All
tools") and one connection:{connectionId} per active connection. They are
read-only, and every change, share or transfer call refuses them.
References
| Document | Topics |
|---|---|
| Endpoints | Every published route by area, with the permission each needs, plus the invite-only families |
| Patterns | Paging, server-side search, capability fields, error handling, retries, and tokens in code |
Companion skills
- elaichi-conventions — the same base facts, condensed, for always-on context.
- elaichi-governance — what each permission in an error means.
- elaichi-connections — the connect flow behind
POST /connection. - elaichi-mcp — the same operations reached as MCP tools instead.