# TeamSquared Public API v1 A flexible, spreadsheet-like data model exposed over HTTP. Surfaces a workspace's schema (columns), views (sheets), and users (rows — each one a user of an AI agent), plus chat history, schema management, prompts, and a versioned KV store. Pre-release: schemas, endpoints, and contracts may change. ## Base URL https://api.teamsquared.ai/api/v1 ## Authentication Bearer token in the `Authorization` header: Authorization: Bearer ext_agent_… Each token is granted permission **scopes** and **bound to a single view**. Tokens with the wrong scope return 403. Tokens are agent-scoped, not user-scoped. ### Scopes - `users:read` — list/get users, history, chat messages, KV - `users:write` — create, update, delete users; add history events - `schema:read` — list field definitions - `schema:write` — create/update field definitions - `prompts:read` — resolve prompt versions - `kv:read` / `kv:write` — read/write the versioned KV store ## Data model & views A **view** is a filtered, column-projected slice of users. The token's bound view determines: - which fields are returned on reads (column projection) - which fields can appear in `filters` and `sortBy` - which rows are returned (the view's filter is AND-ed onto every list query) **Always returned, regardless of view**: `id`, `recipientId`, `createdAt`, `lastResponseAt`, `lastUpdatedAt`. **Reserved**: never returned, regardless of view. Views are **not a write authorization boundary** — a `users:write` token can mutate any field on its agent. Lock view CRUD to admins, since a view edit can change which fields a token exposes. ## Detecting changes `lastUpdatedAt` bumps on every mutation. Two patterns: 1. **Poll-and-diff** — track the last `lastUpdatedAt` per user; skip when unchanged. 2. **Incremental sync** — pass `?updatedSince=`, then save the max `lastUpdatedAt` from the batch for the next loop. The integrating system (e.g. an ERP) keeps its own record of when it last saved each user. Nothing is written back to the API for sync state. ### Code example — ERP sync (Node) ```js const TOKEN = process.env.TOKEN; const API = 'https://api.teamsquared.ai/api/v1'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const erp = { records: new Map(), save(u) { this.records.set(u.id, { recipientId: u.recipientId, name: u.name ?? null, savedLastUpdatedAt: u.lastUpdatedAt, }); }, newestSavedAt() { return [...this.records.values()].map((r) => r.savedLastUpdatedAt).sort().at(-1); }, }; async function initialSync() { const limit = 20; let offset = 0; while (true) { const url = new URL(`${API}/users`); url.searchParams.set('limit', String(limit)); url.searchParams.set('offset', String(offset)); const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } }); const { users, pagination } = await res.json(); for (const u of users) { erp.save(u); const mres = await fetch(`${API}/users/${u.id}/chat-messages?limit=100`, { headers: { Authorization: `Bearer ${TOKEN}` } }); const { messages } = await mres.json(); console.log(JSON.stringify({ user: u, chatMessages: messages }, null, 2)); await sleep(1000); // stay under 60 req/min } if (!pagination.hasMore) break; offset += limit; await sleep(1000); } } // In production, fired by a webhook subscription on user.updated. // The handler calls periodicSync() with the relevant timestamp. async function periodicSync() { const since = erp.newestSavedAt(); const url = new URL(`${API}/users`); url.searchParams.set('updatedSince', since); url.searchParams.set('limit', '200'); const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } }); const { users } = await res.json(); for (const u of users) { const existing = erp.records.get(u.id); if (existing && existing.savedLastUpdatedAt === u.lastUpdatedAt) continue; erp.save(u); } } await initialSync(); await periodicSync(); ``` ## Rate limiting 60 requests per minute per token, fixed-window. Per-token, not per-IP. Excess requests get `429 Too Many Requests` with a `Retry-After: ` header. Honor it. ## Errors | Status | Meaning | Triggered by | |--------|----------------------|------------------------------------------------| | 200 | OK | Read or update succeeded | | 201 | Created | Resource created | | 400 | Bad Request | Missing fields, bad params, filter/sort outside view | | 401 | Unauthorized | Missing or invalid token | | 403 | Forbidden | Missing scope | | 404 | Not Found | Resource doesn't exist | | 409 | Conflict | Duplicate (e.g. existing `recipientId`) | | 429 | Too Many Requests | Rate limit exceeded | | 500 | Internal Server Error| Server error | JSON body: `{ "error": "...", "message": "...", ...context }`. ## Endpoints ### Users - `GET /users` — list (filters, sortBy, sortOrder, limit, offset, updatedSince) · scope: users:read - `POST /users` — create (recipientId required; extra keys → custom data) · scope: users:write - `PATCH /users/:userId` — partial update; only fields in the view are addressable · scope: users:write #### Common list params - `limit` — default 50, max 200 - `offset` — pagination offset - `updatedSince` — ISO 8601; returns rows with `lastUpdatedAt >= since` - `sortBy` — field name (must be exposed by view; reserved fields like `lastUpdatedAt` allowed) - `sortOrder` — `asc` | `desc` (default `desc`) - `filters` — JSON-encoded array of `{ field, operator, value }` #### Filter operators `equals`, `not_equals`, `contains`, `not_contains`, `like`, `not_like`, `is_null`, `is_not_null`, `gt`, `gte`, `lt`, `lte`, `between`, `in`, `not_in`. `is_null` / `is_not_null` take `value: null`. `between` takes `[start, end]`. #### List response shape ```json { "users": [ { "id": …, "recipientId": …, "lastUpdatedAt": …, …viewFields } ], "pagination": { "limit": 50, "offset": 0, "hasMore": true }, "totalCount": 1234 } ``` ### History & chat - `POST /users/:userId/history` — append history event `{ event, message, data? }` · scope: users:write - `GET /users/:userId/history` — list events (limit, offset, event, sinceSeconds) · scope: users:read - `GET /users/:userId/chat-messages` — list messages as `{ role, content }[]` · scope: users:read ### Schema - `GET /user_schema` — list field definitions · scope: schema:read - `POST /user_schema` — create a field · scope: schema:write - `PATCH /user_schema/:fieldName` — update a field · scope: schema:write ### Prompts - `POST /prompts/resolve` — resolve a prompt by name to its current version · scope: prompts:read ### KV (versioned key-value store) - `GET /kv` — list keys · scope: kv:read - `GET /kv/:key` — get value · scope: kv:read - `PUT /kv/:key` — set value · scope: kv:write - `DELETE /kv/:key` — delete · scope: kv:write ## Field naming Custom data fields use `snake_case`. Reserved/system fields use `camelCase` (`lastUpdatedAt`, `recipientId`, etc). ## JSON body conventions - Times: ISO 8601 strings (`2026-05-04T12:34:56.789Z`). - IDs: integers. - `recipientId`: a phone number or other unique channel address (string). ## Webhooks (planned) A webhook subscription on `user.updated` will POST `{ userId, lastUpdatedAt }` to a registered handler on each mutation. Use it to invoke the catch-up loop shown above without polling.