Chat about the import (server-sent events)
const url = 'https://api.sloose.com/ai/chat?connector=con_8f2a&keep=0199a1b2-7c3d-7e4f-8a9b-0c1d2e3f4a5b%3A3';const options = { method: 'POST', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"sessionId":"example","message":"example","columns":["example"],"sampleRows":[{"additionalProperty":"example"}],"sampleRowNumbers":[1],"enabledModules":["example"],"scope":"example","conversation":{"importId":"example","place":{"kind":"source","source":"example"},"question":"example","file":{"digest":"example","reading":"example"},"version":1,"last":{"turnId":"example","waiting":[1],"declined":[1]}}}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request POST \ --url 'https://api.sloose.com/ai/chat?connector=con_8f2a&keep=0199a1b2-7c3d-7e4f-8a9b-0c1d2e3f4a5b%3A3' \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "sessionId": "example", "message": "example", "columns": [ "example" ], "sampleRows": [ { "additionalProperty": "example" } ], "sampleRowNumbers": [ 1 ], "enabledModules": [ "example" ], "scope": "example", "conversation": { "importId": "example", "place": { "kind": "source", "source": "example" }, "question": "example", "file": { "digest": "example", "reading": "example" }, "version": 1, "last": { "turnId": "example", "waiting": [ 1 ], "declined": [ 1 ] } } }'A conversational turn with tools: read the schema, propose a mapping, propose how an existing record is recognised, propose which rows a module takes, propose when a record a row finds is updated, test an expression, explain a problem.
The response is a text/event-stream, not JSON. session is always first and done or error always last; everything between them arrives as the turn produces it, in no order a client may rely on.
| Event | Meaning |
|---|---|
session |
First. { sessionId, requestId } — send sessionId back to continue this conversation. |
text |
Prose, in fragments, as the model writes it. |
tool_use, tool_result |
One pair per tool call, as the turn proceeds. summary is what the model was told, capped at 200 characters and ellipsed when it is longer — so a short result is complete and a long one is cut. It is there so a person can watch the turn work rather than so a client can rebuild the conversation; the answer itself arrives as proposal and text. |
proposal |
{ proposal } — a validated mapping proposal, ready to apply. Interleaved: it is emitted between a tool_use and its tool_result, and more tool calls may follow it, so take the LAST one rather than the first. It carries the sample outputs of any expression, which the model itself is never shown. |
rule |
{ rule } — a validated match rule for one module, in the shape POST /ai/match returns (module, field, expression, method, confidence, reason, intent?, outputs), ready to put on the module’s card. Interleaved like proposal, and the LAST one is the answer. Emitted only when the question was about that module as a whole — its rule (scope.rule), or the step — or was not scoped: a rule the model proposes under a row question, or under a question naming gate or guard but not rule, stays with the model, and the tool_result for it says it was not put on the screen, so the words do not claim it was. |
gate |
{ gate } — a validated Which rows rule for one module (module, expression, reason?): the module takes the rows where it is true. Checked on the rows the model may see and, before it is emitted, on the caller’s own rows, where it must return true or false and throw on none — except a rule that reads records (rec.*), which is decided while the import runs: what it gives on the model’s rows is set aside as deferred rather than checked, and it is not run on the caller’s rows at all (propose_gate skips both checks for it by design). Interleaved like rule, and the LAST one is the answer. Emitted only when the question named the module’s rows (scope.gate) or the module as a whole, or was not scoped; one kept back is said to the model in its tool_result, as a rule’s is. |
guard |
{ guard } — a validated update rule for one module (module, expression, reason?): a record a row finds is updated when it is true and left as it is when false. It is asked only of a row that finds its record, with old (that record as the destination holds it) and next (what the row would write to it) beside row — so one that reads old, next or records (rec.*) is compiled only and decided while the import runs, and is run on no rows here; one that reads only row is checked as a gate is, on the rows the model may see and, before it is emitted, on the caller’s own rows, where it must return true or false and throw on none. Interleaved like rule, and the LAST one is the answer. Emitted only when the question named the update rule (scope.guard) or the module as a whole, or was not scoped; one kept back is said to the model in its tool_result, as a rule’s is. |
notice |
{ message } — something the client should say out loud: a refusal, the tool-call limit reached, or — straight after session, once — that this turn picks up without the conversation’s earlier turns, because the organisation’s AI data setting is stricter than the one they were sent under. |
done |
Last, on success. { sessionId, requestId, credits, usage, model, turns } — the turn finished and the meter is settled. With a conversation, turn too: the turn the answer was kept as, shaped as the conversations list shows it. |
error |
Last, on failure. { code, error } — the turn failed; the reservation is released. AI_UNAVAILABLE is the model unavailable just now — the AI service out of capacity, rate limited or down: nothing was changed or charged, and the same question can be asked again shortly. CHAT_MOVED_ON is a turn that answered a conversation somebody continued elsewhere meanwhile: its answer is not kept, because it was written without that turn, and asking again carries on from where the conversation now is. With a conversation, CONVERSATION_MOVED_ON is the same for a kept one, and CONVERSATION_GONE an answer whose import or file went, or whose file was replaced or read again, while it was written: neither is kept, and neither is charged. |
A turn that fails part-way still emits error rather than closing silently, so a client never has to guess.
A conversation kept with the import. Name it with conversation — the import, and a place of Map fields: a module’s Which rows & existing ({ kind: 'rows', source, module }), its rule (rule), its Map columns (cols) or one of its fields (field, with apiName) — and the question is kept at that place, not in a chat session: no sessionId is sent with it (400 BAD_INPUT), session says sessionId: null, and nothing is written to the organisation’s chat sessions. scope must be what the place is about — the module with one or more of its rules at rows, its rule and nothing else of the card at rule (a question about the rule with its Which rows or update rule is the card’s, at rows), the module or a column at cols, that field at field — or it is refused before anything is charged (400 SCOPE_NOT_PLACE). The server reads the place’s earlier turns itself: the newest six from where replay starts, each question and answer and what it proposed, and, for the newest that proposed anything, what became of each thing it proposed — used, changed by hand (to what), still waiting or turned down, not taken — read off the import as it stands: the recipe for what was used, and the stamps each file’s work keeps for what still waits and what was turned down (conversation.last is not read at these places). They go to the model as words beside the question, never as the tool calls of a chat, and only while the organisation’s AI data setting is no stricter than the loosest of what they were made from; under a stricter one the question starts afresh and a notice says so. Before anything is charged the import is checked as POST /ai/source checks it — this organisation’s and not being deleted (404 IMPORT_NOT_FOUND), one the person may change (403 NOT_YOURS), writing to the connection ?connector= names (409 CONNECTION_MISMATCH), holding the file the place names (404 SOURCE_NOT_FOUND), the file conversation.file names (409 FILE_CHANGED) at the version conversation.version names (409 IMPORT_CHANGED, with the version it is at, updatedBy and updatedAt). What the answer proposed is kept with it as targets: each column for a field (mapping), each column to leave out (skip), a rule (rule), a Which rows (gate) and an update rule (guard), each with what its slot held — and none that the slot already holds.
What a conversation keeps. Every turn is stored as it was sent — the question with the sample rows the organisation’s AI data setting let through, the answer, and the tool calls between — and a later change of setting does not rewrite it. A continuation sends the model the earlier turns only while the setting is no stricter (allow, then masked, then deny) than the one they were sent under; under a stricter one it starts from this turn, says so in a notice, and every later turn keeps to that start. A looser setting does not send them again. A field excluded from AI during a conversation is withheld from the next turn on; the earlier turns are not rewritten, and are replayed as they were sent. A turn is deleted when the plan’s history window has passed since it was asked (up to 30 days longer after a change of plan), a conversation once it holds none, and all of them when the organisation is erased.
What the model sees. Fields the organisation has excluded from AI are withheld from every sample — never sent in any form; their examples stand in for them — from the moment they are excluded: on /ai/chat an earlier turn is replayed as it was first sent — and an organisation whose AI sample policy is deny sends it no rows at all. The same applies to what a failed expression is allowed to say back: a thrown value is a value.
What you see is your own, where it is computed for you. This policy governs the model, not this response. Evidence produced FOR the caller comes from the rows you sent, unmasked, because you are the organisation and the model is not: the sample outputs on a validated proposal, and a formula’s own results and error.
Except what mirrors the conversation. A tool_result event on /ai/chat carries what the MODEL was told, so under masked or deny it carries the model’s copy rather than yours. It is capped at 200 characters and ellipsed past that, so a short result arrives whole and a long one does not — check for the ellipsis rather than assuming either. And none of this says anything about the rest of a response, which may be a generated file or a list of destination options rather than anything of yours.
Authorizations
Section titled “Authorizations”Parameters
Section titled “Parameters”Query Parameters
Section titled “Query Parameters”The connection this is about, by id: one of the organisation’s, or your own. REQUIRED: absent or empty is 400 CONNECTION_REQUIRED, listing the connections this bearer may name, because nothing picks one for a request that does not say (design §14.8). Any other — another organisation’s, or another person’s — is 404 CONNECTION_NOT_FOUND.
Example
con_8f2aThe connection this is about, by id: one of the organisation’s, or your own. REQUIRED: absent or empty is 400 CONNECTION_REQUIRED, listing the connections this bearer may name, because nothing picks one for a request that does not say (design §14.8). Any other — another organisation’s, or another person’s — is 404 CONNECTION_NOT_FOUND.
The versions of the organisation’s helper libraries the import keeps (“Keep v3 for this import”), which this request evaluates in place of the current ones: comma-separated libraryId:version, one per library, as the resolved configuration names each library (libraryId) — at most as many as an import may keep (200). Each is read from the organisation’s own stored publish — nothing a request sends is run. One that names no publish of a library the organisation holds now is 409 KEPT_VERSION_NOT_FOUND, with missing; one that cannot be read is 400 BAD_INPUT. Absent, every library runs as it currently is.
Example
0199a1b2-7c3d-7e4f-8a9b-0c1d2e3f4a5b:3The versions of the organisation’s helper libraries the import keeps (“Keep v3 for this import”), which this request evaluates in place of the current ones: comma-separated libraryId:version, one per library, as the resolved configuration names each library (libraryId) — at most as many as an import may keep (200). Each is read from the organisation’s own stored publish — nothing a request sends is run. One that names no publish of a library the organisation holds now is 409 KEPT_VERSION_NOT_FOUND, with missing; one that cannot be read is 400 BAD_INPUT. Absent, every library runs as it currently is.
Header Parameters
Section titled “Header Parameters”The organisation you believe this session is in. When it is not the organisation the session is in NOW — POST /auth/session/org moves a session without issuing a new token — the request is refused with 409 ORG_CHANGED rather than served against the other organisation’s schema. Omit it and nothing changes.
The organisation you believe this session is in. When it is not the organisation the session is in NOW — POST /auth/session/org moves a session without issuing a new token — the request is refused with 409 ORG_CHANGED rather than served against the other organisation’s schema. Omit it and nothing changes.
Request Bodyrequired
Section titled “Request Bodyrequired”object
Continue an existing chat. Omit to start one. Its earlier turns reach the model again only while the organisation’s AI data setting is no stricter than when they were sent — see “What a conversation keeps”.
One source row: column name → cell value, always as a string.
object
Each sample row’s number — its place in its file, from 1, which excluding or filtering rows never changes — one per row of sampleRows and in its order; a list of another length is refused. The model is shown each sample by it and names a row by it. Absent, a row is known only by its place among the samples.
What this question is about, so the answer does not have to guess which of fifteen mappings is meant. The module is required and the other two narrow it as far as the caller can. module + apiName is that FIELD however it comes to be filled; a column alongside is the proposal the question was asked FROM, taken as context rather than as a condition, because a revision usually changes it — so the answer may hold more than one proposal for that field and none of them need be the one asked about. module + column is that column, whatever it should feed. module alone is the whole step: its mappings, and a rule, gate or guard event if the model proposes one. module + rule is how an existing record of that module is recognised — the card on Map Fields — with rule: true when the card holds no rule yet, or the rule as it stands as { field, expression, method? } so the model corrects what is there; the answer arrives as a rule event. module + gate is which rows the module takes — the Which rows box beside that card — with gate: true when it takes every row, or the rule in force as { expression }; the answer arrives as a gate event. module + guard is when a record a row finds is updated — the …update only when box beside Update it on that card — with guard: true when it always is, or the rule in force as { expression }, and updates beside it saying whether a row that finds its record updates it now — false on a card that leaves one as it is or looks nothing up (every row new, sent with rule: true), where an update rule also turns updating on once it is used, and with no rule set is proposed beside one; the answer arrives as a guard event. updates names nothing without guard. Named together — module + rule + gate, with or without guard, or any two of the three — they are that whole part, and an answer for any rule named may come back. A scope that names rule, gate or guard gets only what it names — a Which rows rule proposed under a question about the rule alone stays with the model, whose tool result then says it was not put on the screen — while module alone takes any of them. A rule, gate or guard scope names nothing else: any of them beside apiName or column is not a scope. Every combination is a subject a person can point at, which is why none of the rest is refused; a value of any other shape is ignored, as it was before this field existed, and a module the organisation does not have is ignored rather than repeated back to the model.
A conversation kept with the import, at one of Map fields’ places — a module’s Which rows & existing (rows), its rule (rule), its Map columns (cols) or one of its fields (field) — whose scope must be what the place is about (400 SCOPE_NOT_PLACE). Not with sessionId: a kept conversation is the import’s, and no chat session is written for it. See “A conversation kept with the import”.
object
object
object
object
object
object
object
object
object
object
What to record as the question when the request asks none in words (a suggestion pressed, say). Absent, the route records the question the request asks.
The file the asker believes the import holds for the place, as the import last said it: the digest of its bytes (null for one stored before digests were kept) and how they are read — the source’s reader, options and worksheet as one string (fileReading in @sloose/core). The answer is made from the rows the request sends and its turn kept against the file the import holds, so a file replaced, or read again under other options, since the asker last read the import is refused before anything is asked or charged (409 FILE_CHANGED). Absent, the question is about whatever file the import holds.
object
The import’s version the asker’s picture of it was built from: what it last wrote or read, with nothing of its own left unwritten. What became of earlier answers is judged, and a new answer’s was recorded, from the import as the server holds it, so a question asked of a picture the import has moved on from — another person’s edit, or the asker’s own that was never saved — is refused before anything is asked or charged (409 IMPORT_CHANGED, with the version it is at and who changed it last). Absent, the question is about the import as it is.
The kept turn whose answer is live on the page — its newest that proposed anything, which an answer in words alone leaves standing — and which of its targets, by position, still wait on the page (waiting) or were turned down there (declined): what only the page knows. Read only at the formula editor’s place (formula), whose answer waits in the editor and nowhere else, and taken there only when it names the turn the question is judged against: the newest of the turns sent that proposed anything. At Source and every place of Map fields the import keeps its guesses stamped with the answer that made them, so what still waits and what was turned down is read off the stamps each file’s work keeps, and a last sent there is not read.
object
Responses
Section titled “Responses”The event stream. See the table above for the event sequence.
Server-sent events.
The body did not match the schema (BAD_INPUT, with issues — conversation with sessionId is one); the request named no connection (CONNECTION_REQUIRED, with connections); or keep cannot be read (BAD_INPUT); conversation names a place this route does not answer (PLACE_NOT_ANSWERED), or scope is not what that place is about (SCOPE_NOT_PLACE).
object
Examplegenerated
{ "error": "example", "code": "example"}No bearer, or one that no longer resolves: a session that is unknown or expired (code: AUTH_REQUIRED), or an API token that was revoked or expired (TOKEN_REVOKED, TOKEN_EXPIRED), whose organisation is gone (ORG_NOT_FOUND) or whose member or user row is (MEMBERSHIP_GONE).
The error envelope every non-2xx answer uses.
object
Human-readable explanation.
Machine-readable reason. Absent on a few legacy 400s.
Examplegenerated
{ "error": "example", "code": "example"}Not enough AI credits (CREDITS_EXHAUSTED), or the organisation is suspended (SUSPENDED). balance shows what is left, needed what this asked for, planIncludesCredits whether the plan includes any each period, and renewsAt, where a subscription renews them, when they come back.
object
AI credits. Included credits reset each period; purchased ones do not.
object
Included + purchased − reserved
The least the action would hold: its price, or for an action priced by how much it reads, the cheapest band, since such an action holds what the balance covers of its dearest band. The balance fell short of it — which is not the same as nothing left.
When the credits ran short (CREDITS_EXHAUSTED) under a subscription that renews them — active, not set to cancel, and renewing on a plan with an allowance, whether charged to a card or billed by invoice (whose credits come as each invoice is issued, #1337): when that allowance comes back, the end of the current period. Absent on a plan that includes none, on a trial, under a subscription that is cancelled, set to cancel or past due, when the plan it moves to at the period’s end includes none, for a suspension, and when the plan could not be read.
When the credits ran short (CREDITS_EXHAUSTED): whether the organisation’s plan, as the catalogue has it, includes AI credits each period — a staff override changes this period’s limits, not that. Absent for a suspension, and when the plan could not be read — which says nothing either way.
Examplegenerated
{ "error": "example", "code": "example", "balance": { "included": 1, "purchased": 1, "reserved": 1, "available": 1, "periodStart": "example", "periodEnd": "example" }, "needed": 1, "renewsAt": "example", "planIncludesCredits": true}That chat session belongs to somebody else (SESSION_FORBIDDEN), or the import conversation names is one this person may not change: an operator asks about only an import they started (NOT_YOURS). Like every route behind a session: a session signed in but in no organisation is refused code: NO_ORG, a banned person code: BANNED, and a closed organisation code: ORG_CLOSED.
object
Example
{ "code": "SESSION_FORBIDDEN"}No such organisation (ORG_NOT_FOUND); ?connector= names a connection this person may not use: another organisation’s, or another person’s (CONNECTION_NOT_FOUND); sessionId names no conversation in this organisation — including one the history window has deleted (SESSION_NOT_FOUND), so start a new one by leaving sessionId out; the import conversation names is not this organisation’s, or is being deleted (IMPORT_NOT_FOUND); or it does not hold the file the place names (SOURCE_NOT_FOUND).
object
Example
{ "code": "ORG_NOT_FOUND"}The organisation has no module registry yet (ORG_NOT_CONFIGURED); the session is in a different organisation now (ORG_CHANGED); a version keep names is not one the organisation holds now (KEPT_VERSION_NOT_FOUND, with missing); the import conversation names writes to another connection than ?connector= names (CONNECTION_MISMATCH); the file conversation.file names is not the one the import holds (FILE_CHANGED); or the import moved on from conversation.version (IMPORT_CHANGED, with the version it is at, updatedBy and updatedAt). None of these is charged.
object
IMPORT_CHANGED only: the version the import is at now.
IMPORT_CHANGED only: who changed it last.
object
The app an agent changed it through over MCP, when its last write was one: userId is then the person whose agent it was.
IMPORT_CHANGED only: when it was last changed.
KEPT_VERSION_NOT_FOUND only: each kept publish keep names that the organisation does not hold now, by its library’s id and number.
object
Example
{ "code": "ORG_NOT_CONFIGURED"}The model declined. category says why.
object
Example
{ "code": "AI_REFUSED"}The model’s answer could not be used.
object
Example
{ "code": "AI_BAD_OUTPUT"}AI is not configured on this deployment. The model unavailable just now is said once the stream is open, as its error event (AI_UNAVAILABLE). Or the restore switch is on (code: MAINTENANCE): every route but /health and /robots.txt is refused before anything is read, with Retry-After (the operator’s ADMIN_TOKEN alone is let through to POST /staff/people/{id}/delete, and to nothing else).
Headers
Section titled “Headers”Seconds until asking again is worth it (300 while the switch is on).