Transfer connection ownership
/connection/{id}/transfer
Changes `owner_user_id` — and nothing else. Transfer is one thing only: the new owner gets implicit `edit`, plus delete and transfer, which no grant confers; every existing grant on the connection is left exactly as it was, so who else could already reach it is unaffected. The removed `owner_scope`/`team_id` body is gone along with the `scope` it moved between — to change who can reach a connection, use `POST /connection/{id}/share` and `DELETE /connection/{id}/share/{aclId}` instead. Only the Elaichi row changes — the vaulted account is untouched, so toolbox entries pinned to this connection keep resolving and executing across the transfer. This is the non-destructive alternative to delete when a member leaves. Allowed for the current owner only — no `connection:manage` fallback any more (removed 2026-09-04). What it DOES cost the outgoing owner: their reach came from `owner_user_id`, not a grant, so unless a grant of their own already covered them they hold nothing afterwards (`access: null`) — and because a toolbox entry runs on its DELEGATOR's standing on the pinned connection, every entry THEY pinned stops resolving, for everyone including the new owner. `retain_access: true` prevents exactly that by granting them `use` as an ordinary, listed, revocable share in the same operation; only the connection's real owner may use it. Read `GET /connection/{id}/transfer-preview` first to see the same numbers before committing. **`connected_by_user_id` is NOT rewritten**, and that is the point: the vaulted credential still belongs to whoever authorized it, so the response carries the new `owner_user_id` beside the unchanged `connected_by_user_id`/`connected_by_label`. A client that renders "Connected by" off `owner_user_id` will claim the new owner authenticated an account they never touched. The new owner must be an ACTIVE member: a suspended one is refused with `400 member_not_active`.
Path Parameters
Connection id (conn_…).
Request Body
New owning member (usr_…).
Keep the OUTGOING owner's access as an ordinary use share, so the toolbox entries they pinned to this connection keep working. Defaults to false. Refused (403) for any caller who is not the connection's own owner — offboarding never passes it, and a departing member keeps nothing.
Response Body
The caller's relationship to this connection: owner, or a granted view/use/edit level. There is no oversight fallback for connections — this is never "oversight"; a connection the caller neither owns nor holds a grant on is not visible to them at all, whatever organization permission they hold.
Present only when can_share is true. Bounded by design — page GET /connection/{id}/share for the grants themselves.
viewusenull
At most 5 grantees, broadest first (org, then teams, then members), for a hover preview.
4 properties
userteamorg
viewuse
Display name; null for org grants and for grantees no longer in the org.
Every grant, the org-wide one included.
How the CALLER reaches this connection — not who else can. owner — they own it. direct — a grant naming them personally. team — a grant to a team they belong to (or, per §6.2, one they administer), named in access_via_team. org — an organization-wide grant. When several sources apply the BROADEST wins and the caller's access level is not consulted: owner, else org, else team, else direct. So a caller granted edit personally AND view org-wide reads org — a narrower grant must never mask org-wide exposure, since this field says how far the connection reaches, not what the caller may do with it. Deliberately NOT gated on can_see_shares, and deliberately not a widening of it: this is the caller's OWN grant and their OWN team memberships, so a view/use grantee receives it while the grantee list — information about colleagues — stays closed to them. No other member is ever named. ABSENT when nothing reaches the caller — a transfer answering the ex-owner of a resource that was never shared, or a catalog row browsed with no grant behind it. Absent means "no source to name", never "not permitted", and never an implied org.
ownerdirectteamorg
Present exactly when access_via is team, absent otherwise. The team the caller reaches this connection through — one of their own teams, never a disclosure about anybody else.
Team id (team_…).
Team display name, or null when the team no longer resolves in the directory — the same null contract every resolved grantee name carries.
Exact number of toolboxes with an entry pinned to this connection.
At most five, breaking ones first. A preview — read the counts, not this length.
The outgoing owner pinned at least one of this toolbox's entries on the connection.
toolbox
Exact number of those that stopped working, because the outgoing owner pinned them and lost the access they run on. Already 0 when any grant saved them — the retain_access one, or an org-wide/team share the outgoing owner already held.
Present on every list and detail row. Whether the caller may edit this connection's own settings — owner, or edit access. No connection:manage fallback. Computed server-side; same can_manage name used by toolbox and connector for the identical capability.
can_share, verbatim — a THIRD formula, deliberately not an alias for can_manage: an edit grantee whose role omits connection:share (Auditor, Guest, any custom role) can reconfigure or reconnect the connection but was never meant to grant or revoke someone else's access to it, so can_manage: true on that row must not imply this control too. Mirrors DELETE /connection/{id}/share/{aclId}'s own gate exactly. Present on every list and detail row.
Whether the caller may share this connection — owner, edit access, or a team admin on a team-granted one. No connection:manage fallback. Computed server-side (CLAUDE.md's API-first rule); the console reads this rather than re-deriving it from owner_user_id/permissions.
Present on every list and detail row. Whether the caller may transfer or delete this connection — owner, full stop. No connection:manage fallback. Deliberately narrower than can_manage: an edit grant lets someone use and reconfigure a connection, never give it away or destroy it.
Display name for connected_by_user_id, resolved server-side one batch per page (name, else email). Present on every branch that returns a connection, commands included. A null HERE beside a NON-null connected_by_user_id is a fact about the person, not a failed lookup: they have left the organization, and clients render "Former member" — the same word the GET /audit-log actor summary uses. Compare the two fields, never one alone: a null id means "not recorded", a null label means "departed".
Who AUTHENTICATED this account (usr_…) — the member whose third-party credential the vault actually holds. Written once at creation and never rewritten. Distinct from owner_user_id on purpose: POST /connection/{id}/transfer moves the owner and deliberately does NOT touch the vault account, so after a transfer these are two different people and only this one may be rendered as "Connected by". Null means NOT RECORDED, never "nobody" — every connection created before this field existed is null, because nothing server-side could tell a never-transferred row (where owner_user_id would have been the honest answer) from a transferred one, and a fabricated attribution is worse than an absent one. Render nothing for a null.
Which connector this account is on.
Connection id (conn_…).
Owner summary. Currently never populated on a connection response — no list route resolves it today — but kept in the schema in case a future route does; read owner_user_id plus a member lookup instead.
User id (usr_…) — same value as owner_user_id.
Owning member (usr_…); calls run as this identity. Owner is implicit edit, plus delete and transfer — the two things no share confers. Move it with POST /connection/{id}/transfer. This is who is ACCOUNTABLE for the connection now, which after a transfer is not who authenticated it — see connected_by_user_id.
True iff a retained use grant was actually made for the outgoing owner. False when they never asked for one — which is not the same as having lost access, since a grant they already held still counts: read access for that.
Vault account holding the credential. Null until the connect flow completes.
Present only when the caller may see the ACL: owner or edit access. A view/use recipient is never told who else the connection is shared with. Absent (not empty) when the caller may not see it.
User id (usr_…) or team id (team_…). Null for a grantee_type: "org" grant.
user and team grants target one grantee_id; org applies to every member of the organization and takes no id.
userteamorg
ACL entry id — the :aclId a DELETE .../share/{aclId} call takes.
Same ladder for every shareable resource, low to high. view — see it exists, read its metadata/config; cannot exercise it. use — view + exercise it, resource-specific: run tools through a connection; execute a toolbox's tools through its bound connections (this DELEGATES — the caller runs through each entry's pinning editor's own authority, not necessarily their own); stamp a new toolbox by copying a template's entries; create connections from a connector. edit — use + change its settings, entries and its own grants.
viewuseedit
The shared resource's id (a connector's slug, for that type).
connectiontoolboxtemplateconnector
pending = created but not yet authenticated (open connect_url); active = usable; needs_reauth = the credential expired or was revoked upstream, call reconnect; disconnected = no longer usable. Refreshed lazily against the vault when connections are read.
pendingactiveneeds_reauthdisconnected
curl -X POST 'https://api.elaichi.ai/connection/<id>/transfer' \
-H 'Authorization: Bearer $ELAICHI_API_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"owner_user_id":"your_owner_user_id","retain_access":true}'const body = {
"owner_user_id": "your_owner_user_id",
"retain_access": true
};
const response = await fetch('https://api.elaichi.ai/connection/<id>/transfer', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + process.env.ELAICHI_API_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
console.log(data);import os
import requests
url = "https://api.elaichi.ai/connection/<id>/transfer"
headers = {
"Authorization": f"Bearer {os.environ['ELAICHI_API_TOKEN']}",
"Content-Type": "application/json",
}
payload = {
"owner_user_id": "your_owner_user_id",
"retain_access": True
}
response = requests.post(url, headers=headers, json=payload)
print(response.json())