diff --git a/README.md b/README.md index b3d3f00..0e8cac8 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ bun type-check # TypeScript type checking Skills are plain markdown files in `use-crystallize/skills/` — no build step required. Each skill has a `SKILL.md` with YAML frontmatter and an optional `references/` directory with supporting docs. -Available skills: `bookable-resources`, `content-json`, `content-model`, `data-creation`, `information-architecture`, `js-api-client`, `mass-operations`, `mutation`, `payments`, `permissions`, `plugins`, `pricing`, `query`, `responsive-images`, `subscriptions`, `taxonomy`, `vector-ranking` — the directory itself is the authoritative list. +Available skills: `agent-tenants`, `bookable-resources`, `content-json`, `content-model`, `data-creation`, `information-architecture`, `js-api-client`, `mass-operations`, `mutation`, `payments`, `permissions`, `plugins`, `pricing`, `query`, `responsive-images`, `subscriptions`, `taxonomy`, `vector-ranking` — the directory itself is the authoritative list. ## Using the Claude Plugin diff --git a/use-crystallize/skills/agent-tenants/SKILL.md b/use-crystallize/skills/agent-tenants/SKILL.md new file mode 100644 index 0000000..2c7f327 --- /dev/null +++ b/use-crystallize/skills/agent-tenants/SKILL.md @@ -0,0 +1,278 @@ +--- +name: agent-tenants +description: > + Get a Crystallize tenant as an AI agent, for a user who has no Crystallize account, and hand it over + to them before it expires. Use when the user has no tenant or no access token and wants the agent to + set one up, asks for a sandbox or demo tenant, or wants an agent-made tenant handed over, claimed or + kept. Trigger on "create a tenant", "new tenant", "set up Crystallize for me", "I don't have a + Crystallize account", "sign up", "provision", "agent tenant", "/agents/tenants", "hand over the + tenant", "claim the tenant", "invite me", "createInviteToken", "tenantAdmin", "get-access", "tenant + expires", "tenant deleted". +metadata: + author: Crystallize + version: "1.0" +--- + +# Crystallize Agent Tenants + +An agent with no Crystallize account gets a working tenant and an access token from **one unauthenticated +HTTP call**. It builds in the tenant with the normal APIs, then hands it to a human through an ordinary +`tenantAdmin` invite. **A tenant no human joins is deleted 72 hours after it was provisioned**, with +everything in it. + +So the user does not sign up first, and does not create an access token for you. You provision, build, and +send them one link. + +## Before you provision + +- **The user already has a tenant?** Use it: ask for its identifier and an access token. Do not provision. +- **You already provisioned one for this project?** Reuse it (read it back from where you stored it in step 1), + unless its `expiresAt` has passed with no human on it: then it is gone, and you provision again. Every call + makes a new tenant, a new agent user and a new token, and an unused one lingers until it expires. +- Otherwise, tell the user in one line what is about to happen: a tenant named after their project is + created now, and deleted in 72 hours unless they open the invite link you will give them. + +## The flow + +```text +1 Provision POST /agents/tenants, no auth → tenant, access token, expiresAt, apiUrl +2 Build Core API at apiUrl with that token → shapes, items, everything else +3 Invite createInviteToken(role: "tenantAdmin") → invite token +4 Hand off the App's /get-access?token=… link → the human joins as tenantAdmin +5 Check the tenant's members on the PIM API → a human tenantAdmin = claimed +``` + +Only step 1 is specific to agents. Steps 2 to 5 are the API a human tenant admin already uses. + +| Used for | Host | +| ------------------------- | ------------------------------------- | +| Provisioning and Core API | `https://api.crystallize.com` | +| App (the invite link) | `https://app.crystallize.com` | +| PIM API (members) | `https://pim.crystallize.com/graphql` | + +## 1 · Provision + +```bash +curl -s -X POST https://api.crystallize.com/agents/tenants \ + -H 'content-type: application/json' \ + -d '{"name":"Acme Coffee","identifier":"acme-coffee","source":"claude-code"}' +``` + +| Field | Required | Rules | +| ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `name` | yes | String, 1 to 100 characters once trimmed. The tenant's display name. | +| `identifier` | no | String, 1 to 100 characters. Lowercased; anything outside `a-z 0-9 -` becomes `-`. Without it (or if nothing usable remains) it comes from `name`, else `agent-<8 hex>`. | +| `source` | no | String, 1 to 100 characters. Who is provisioning (`claude-code`, `cursor`). Shown in Crystallize's Slack and as the agent user's name. | + +Any other key is ignored, `omitDefaultShapes` included. Values that are not plain strings are refused with 400. + +Response, `201 Created`: + +```json +{ + "tenant": { "id": "6adbb89b790dd26b73debb6b", "identifier": "acme-coffee", "name": "Acme Coffee" }, + "accessToken": { "id": "5a495b4547e21721ecca", "secret": "" }, + "expiresAt": "2026-10-26T19:42:19.887Z", + "apiUrl": "https://api.crystallize.com/@acme-coffee" +} +``` + +- **Store all of it now.** The secret is returned this once and cannot be read again: lose it and you lose the + tenant. Write the values where the project keeps secrets, never in a commit: a `.env` that `.gitignore` lists + (add the line if it is missing). + + ```bash + CRYSTALLIZE_TENANT_ID=6adbb89b790dd26b73debb6b + CRYSTALLIZE_TENANT_IDENTIFIER=acme-coffee + CRYSTALLIZE_API_URL=https://api.crystallize.com/@acme-coffee + CRYSTALLIZE_ACCESS_TOKEN_ID=5a495b4547e21721ecca + CRYSTALLIZE_ACCESS_TOKEN_SECRET= + CRYSTALLIZE_TENANT_EXPIRES_AT=2026-10-26T19:42:19.887Z + ``` + +- **Use the returned `identifier`**, not the one you sent. When the wanted one is taken, a number from 1 to + 200 is appended (`acme-coffee-37`). +- **`apiUrl` is the tenant's Core API endpoint.** Every call in steps 2 and 3 goes there. +- **`expiresAt` is 72 hours after provisioning.** The invite in step 3 must expire no later than this. + +## 2 · Build with the token + +You are a tenant admin like any other: every Core API call goes to `apiUrl` with the two token headers. +Nothing about an agent tenant is special. + +```bash +curl -s "$CRYSTALLIZE_API_URL" \ + -H 'content-type: application/json' \ + -H "X-Crystallize-Access-Token-Id: $CRYSTALLIZE_ACCESS_TOKEN_ID" \ + -H "X-Crystallize-Access-Token-Secret: $CRYSTALLIZE_ACCESS_TOKEN_SECRET" \ + -d '{"query":"{ shapes { edges { node { identifier name type } } } }"}' +# → { "data": { "shapes": { "edges": null } } } +``` + +The tenant starts with **no shapes**. It does have a VAT type, a stock location, a price variant and a +catalogue root, like any new tenant. So the first build step is usually a shape. Mutations return a union: +the type on success, or an error implementing `BasicError`, so always select both. + +```graphql +mutation { + createShape( + input: { + identifier: "coffee" + name: "Coffee" + type: product + components: [ + { id: "origin", name: "Origin", type: singleLine } + { id: "tasting-notes", name: "Tasting notes", type: richText } + ] + } + ) { + __typename + ... on Shape { + identifier + } + ... on BasicError { + errorName + message + } + } +} +``` + +From here on, the other Crystallize skills apply unchanged with this token: [[content-model]] to design +shapes, [[mutation]] and [[query]] for the Core API, and [[js-api-client]] in code (`tenantIdentifier` is the +returned identifier). + +**Prefer one mass operations file over a string of one-off mutations** for anything beyond a first shape. +Put the shapes, pieces, folders and items in a [[mass-operations]] file and run it on the tenant with the same +token. Until a human claims it, the tenant is disposable. With the file kept in the project, a tenant that +expires unclaimed, or a build the user wants redone, is one new tenant and one replay away. + +## 3 · Create the claim invite + +The claim is an ordinary invite: a `tenantAdmin` invite token on the tenant, created with the same token. +Pass the tenant's `expiresAt` as the invite's `expiresAt`. + +```bash +curl -s "$CRYSTALLIZE_API_URL" \ + -H 'content-type: application/json' \ + -H "X-Crystallize-Access-Token-Id: $CRYSTALLIZE_ACCESS_TOKEN_ID" \ + -H "X-Crystallize-Access-Token-Secret: $CRYSTALLIZE_ACCESS_TOKEN_SECRET" \ + -d @- <<'EOF' +{ + "query": "mutation Invite($expiresAt: DateTime) { createInviteToken(role: \"tenantAdmin\", expiresAt: $expiresAt) { __typename ... on InviteToken { id token expiresAt } ... on BasicError { errorName message } } }", + "variables": { "expiresAt": "2026-10-26T19:42:19.887Z" } +} +EOF +# → { "data": { "createInviteToken": { "__typename": "InviteToken", "id": "…", "token": "", "expiresAt": "…" } } } +``` + +- **The claim invite's role must be `tenantAdmin`**, for whoever will own the tenant: the user, or someone they + name. Only a human `tenantAdmin` claims the tenant. An invite with any other role lets a human in and keeps the + tenant from deletion, but never claims it: send one on top of the admin invite, never instead of it. +- **`expiresAt` is optional in the schema; send it anyway**, no later than the tenant's. An invite that + outlives the tenant is useless. +- **Errors come back in the result, not as HTTP errors.** An unknown role gives `RoleNotFoundError` instead of + `InviteToken`. +- You can create a new invite at any time, for instance when one has expired. + +## 4 · Hand the invite to the human + +```text +https://app.crystallize.com/get-access?token= +``` + +The human opens it, signs in or signs up, and joins the tenant as `tenantAdmin`. **You cannot redeem it for +them**: invite redemption is refused to agent users. Whoever opens the link becomes `tenantAdmin`, so give it +only to the person who will own the tenant, and keep it out of commits. + +Always give the deadline with the link, as a date and time (the user's time zone if you know it, else UTC): + +> Your Crystallize tenant **Acme Coffee** (`acme-coffee`) is ready. Open this link to sign in and take it over: +> https://app.crystallize.com/get-access?token=… +> Open it before **Monday 26 October 2026, 19:42 UTC**. If no one has joined by then, the tenant and everything +> in it is deleted. + +If the user cannot open it in time, someone they name can open a `tenantAdmin` link instead and claim the tenant. + +What happens next needs no call from you: + +1. The App redeems the token (`redeemInviteToken`) and the human becomes `tenantAdmin` on the tenant. +2. At the next cleanup run, within 2 hours, Crystallize sees a human `tenantAdmin` and the tenant is claimed: + its provisioning record is deleted, so it can never be removed automatically. +3. Crystallize's Slack gets a "claimed" message, and the human is subscribed to the newsletter under the usual + signup rules. + +## 5 · Check whether the human joined (optional) + +The claim happens whether or not you check. Check when the user says they have joined, so you can confirm it +worked; do not poll on a loop. The Core API has no user queries and no API reads an invite back, so the +tenant's members on the PIM API are the signal. Same two token headers: + +```graphql +query Members($id: ID!) { + tenant { + get(id: $id) { + users { + role + user { + id + firstName + lastName + email + kind + } + } + } + } +} +``` + +Variables: `{ "id": "6adbb89b790dd26b73debb6b" }` (the `tenant.id` from step 1). Before the human joins, the +only member is the agent: `role: "tenantAdmin"`, `firstName: "Agent"`, `email: null`, `kind: "agent"`. Once +they join, a second member appears with `role: "tenantAdmin"` and `kind: "human"`, with their name and email. + +## Rules + +| Topic | Rule | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Lifetime | `expiresAt` = provisioning + 72 h. A cleanup runs every 2 hours, so an unclaimed tenant goes between 72 h and about 74 h later, with all its content, the agent user and its token. | +| What keeps a tenant | Any human with a role on it. A human `tenantAdmin` claims it for good. A human with a lesser role keeps it from deletion but never claims it: it waits for a human admin. | +| Nothing else | No call extends the 72 hours. Only a human joining keeps the tenant. | +| After the claim | The agent stays `tenantAdmin` and its token keeps working. The human can remove the agent user in the App; the cleanup then deletes it and its tokens. | +| One tenant per call | An agent cannot create tenants any other way (`createTenant` on `/@me`, PIM `tenant.create`), copy a tenant, or redeem invites. Another tenant is another `POST /agents/tenants`. | +| The agent user | Named `Agent `, no email, and no one can log in as it. It acts only through its token, which works on its own tenant only. | +| Rate limit | None specific to provisioning. The API firewall's per-IP limit applies to every call (429). | + +## Errors + +Provisioning answers plain HTTP errors with a JSON body, `{ "message": "…", "statusCode": 400 }`. GraphQL calls +answer HTTP 200 with the error inside the result union (`__typename`, `errorName`, `message`). + +| Call | Status | Message | What to do | +| ---------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Provision | 400 | `The body must be a JSON object.` | Send a JSON object with `content-type: application/json`. | +| Provision | 400 | `name is required and must be a string of 1 to 100 characters.` | Fix `name`. An object or a number in `name` gets the same answer. | +| Provision | 400 | `identifier must be a string of 1 to 100 characters.` | Fix or drop `identifier`. | +| Provision | 400 | `source must be a string of 1 to 100 characters.` | Fix or drop `source`. | +| Provision | 409 | `That tenant identifier was just taken. Please retry.` | Retry the same call once: a new identifier is picked. Nothing was created. | +| Provision | 500 | `Provisioning failed (trace ).` | Retry later; give the trace id to Crystallize support if it repeats. Half-created is cleaned. | +| Provision | 404 | none | The endpoint is not deployed on that host. Tell the user. | +| Any | 429 | none | Too many requests from this IP: back off and retry. | +| `createInviteToken` | 200 | `RoleNotFoundError`: `Role not found: (tenant id )` | The claim invite takes `tenantAdmin`; any other role must exist on the tenant. | +| Agent-forbidden: `/@me` `createTenant`, invite redemption, tenant copy | 200 | `UnauthorizedError`: `This operation is not available to agent users.` | Do not retry. Create tenants with step 1 only, and let humans redeem invites. | + +## Common mistakes + +| Mistake | What happens | Instead | +| ----------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| Sending the user to sign up and create an access token | The friction this endpoint removes | Provision, build, then send one invite link | +| Not storing the secret right away | The tenant is unreachable: the secret cannot be read again | Write every value to the project's secrets in step 1 | +| Calling `/@` | The calls fail when the identifier was taken and got a suffix | Use the returned `identifier` and `apiUrl` | +| Provisioning again to start over while the tenant is alive | A second tenant, agent user and token; the first lingers for 72 h | Fix or recreate the shapes in the tenant you have | +| Building only with one-off mutations | An expired tenant takes the whole build with it | Keep the build as a mass operations file and replay it into a new tenant | +| Sending only an invite with another role | The human gets in and keeps the tenant, but it is never claimed | Always create a `tenantAdmin` invite for whoever will own it; others go on top | +| An invite with no `expiresAt`, or one past the tenant's | A link that still looks valid after the tenant is gone | Pass the tenant's `expiresAt` | +| Giving the link without the deadline | The user opens it on day four and finds nothing | Always state the date and time with the link | +| Saying "it's yours, nothing to transfer" after provisioning | The user never opens the invite, and the tenant is deleted | The tenant is the agent's until a human `tenantAdmin` joins | + +Related: [[permissions]] for roles and access tokens once the human owns the tenant.