Skip to content

Partner server chat — AGNT2-461 ​

A partner server can create an employee’s own conversation with the store’s Admin Agent using explicitly approved chat scopes. The same key may also hold provisioning scopes. Keys with provisioning scopes alone cannot send messages or read history. Browser login remains a separate protocol.

Implementation and evidence ​

Implemented in this branch; full release evidence is recorded in the committed AGNT2-461 report. No merge, deployment, live credential issuance or real Store/OpenCart result is implied by these fixtures.

EvidenceState
AGNT2-461 branchImplemented; full API run completed and both failed suites passed targeted reruns
Foundation pull requests433 #344, 437 #347, 457 #348; these do not establish server-chat release
Server-chat merge/deploymentNot performed by this worker
Local mock MCPHTTP setup, message, retry, SSE and history verified without paid provider calls
Actual local Store/OpenCartCoordinator reports bootstrap, employee grant, catalog_get product9001, completed run, retry, SSE resume and history passed; model completion synthetic
Live devNot verified; operator gate and explicitly approved chat scopes required

Each external employee reuses a permanent namespace-bound identity, never the team owner. Explicit membership synchronization requires chat:membership; owner/admin/member role changes advance membershipVersion. sourceRevision is monotonic reconciliation state, separate from subjectVersion/membershipVersion. A newer unchanged confirmation may renew a lease for at most five minutes; duplicate/stale confirmations cannot renew it. Revoked memberships and deleted identities never resurrect. Promote a replacement before last-owner demotion; security revocation remains possible without electing another owner.

Create conversation first, obtain Store’s grant for the returned canonical conversationId (ChatThread.id), bind with expectedVersion=0, then submit the human turn. The response names exactly one operator-admitted trustedContext MCP server and destination; zero/multiple candidates fail closed. A conversation-only agent — provisioned from an inline profile with no MCP — has none at all: the response carries mcp: null, no Store grant or bind is needed, GET context reports it inactive and PUT context is refused. Grants remain encrypted and must match actor, organization, environment, team, store/agent, conversation and destination. Static-only initialize/tools/list cannot access Store data. Turn and MCP-call headers are server-owned.

Idempotency receipts and immutable identity mappings persist across credential rotation. Identical retries return the original conversation/run; changed input is 409 CHAT_CONFLICT. Concurrent messages in a conversation conflict while a run is active. Network disconnect does not cancel a turn. An uncertain claimed run becomes interrupted and is never automatically executed again. Read history newest-first using its opaque nextCursor. A turn ending with done.stopReason: completed is completed, even after failed tool steps. Only a turn ending on tool_error fails with TOOL_REFUSED; max_iterations remains limited. Use run_status or run polling as outcome authority. Both include toolErrors: [{toolCallId, name, errorCode}], empty when no tool failed. errorCode is the safe TOOL_FAILED, never raw arguments or results. A strict client may treat any nonempty toolErrors as unsuccessful.

Execution checks current credential, organization, external identity, actual membership/role, subject epoch and lease at admission, stream delivery and effects. Shared notes/curation, devices and agent-management tools are excluded from employee turns. Worker hands are offered only through the agent's own operator-approved grant (setupProfile.worker.tools in the frozen provisioning profile); the worker is raised under the turn's execution authority, re-checked when the queued request is processed, never through a team membership the external actor does not have; normal quotas, model pinning, usage and trusted MCP remain. Thread/agent/team removal erases durable content/events and tombstones ownership; mappings/receipt hashes remain reserved. Broader invite/global lifecycle tasks AGNT2-442/444 are not claimed complete.

Errors: CREDENTIAL_REJECTED 401; CHAT_UNAVAILABLE 404; CHAT_CONFLICT 409; MCP_CONTEXT_CONFLICT 409; CHAT_INVALID 400; CHAT_CONFIGURATION 503; foundation EXECUTION_DENIED and MCP_CONTEXT_DENIED. No raw provider error is returned. Live dev proof still requires deployed routes/operator profile and separately approved server_chat credentials; no key is widened to bypass this requirement.

HTTP contract ​

Authorization: Bearer <organization key with required chat scope>; base /v1/orgs/:orgId/chat. JSON: {success:true,data:...}. PARTNER_API_ENVIRONMENT=test|production is independent of NODE_ENV.

Method/pathScopeRequestResponse data
PUT /membershipschat:membership{externalUserId, externalTeamId, sourceRevision, role, active, validUntil}{actorId,teamId,sourceRevision,role,subjectVersion,membershipVersion,active,validUntil}
POST /conversationschat:create{externalUserId,externalTeamId,agentId} + Idempotency-Key`{conversationId,actorId,teamId,agentId,mcp:
GET /conversations/:conversationId/contextchat:contextactor query{version,expiresAt,active} (no grant)
PUT /conversations/:conversationId/contextchat:context{externalUserId,externalTeamId,serverId,destination,grant,expiresAt,expectedVersion}{version}
POST /conversations/:conversationId/attachmentschat:runmultipart file + fields externalUserId, externalTeamId201 {id,kind,mimeType,name,size,createdAt}
POST /conversations/:conversationId/messageschat:run{externalUserId,externalTeamId,message?,attachmentIds?,timezone?} + Idempotency-Key202 {runId,conversationId,turnId,status,reason,createdAt}
GET /conversations/:conversationId/runs/:runIdchat:readquery externalUserId,externalTeamIdsame run shape
GET /conversations/:conversationId/runs/:runId/eventschat:readquery externalUserId,externalTeamId; Last-Event-ID optionalSSE id=runId:sequence, event=type, data=JSON
GET /conversations/:conversationId/messageschat:readquery externalUserId,externalTeamId,cursor?,limit? (1–200, default 50){items:[{id,turnId,role,text,createdAt,attachments,parts?}],nextCursor}
GET /conversations/:conversationId/attachments/:attachmentId/contentchat:readactor query, or none with chat:conversation:readthe file's bytes with its Content-Type

Attachments — AGNT2-622 ​

The same files and rules as the chat in the Agentfy app (api/src/slices/agent/chat/domain/chatAttachment.types.ts):

  • Types: pictures image/png, image/jpeg, image/gif, image/webp (kind: "image", the model looks at them); text text/plain, text/markdown, text/csv, application/json (kind: "text", the model reads the first 20 000 characters and is told when a file is cut).
  • Limits: 3 MiB per file, 5 files per message. A file uploaded and never sent is removed after 1 hour.
  • Flow: upload each file → send the message with their ids in attachmentIds. message may be empty when attachmentIds is not. The ids are checked when the message is accepted; a replay of the same Idempotency-Key returns the original run.
  • Access: a file belongs to the conversation it was uploaded into. Another conversation or organization cannot send or read it with the right id: the answer is 404. Only the conversation's actor can upload into it.
  • History: each message carries attachments: [{id,kind,mimeType,name,size,createdAt}]; answers carry [].
  • Content: served with Cache-Control: private, no-store, X-Content-Type-Options: nosniff and Content-Security-Policy: default-src 'none'; sandbox, so a text file is never rendered as markup.
  • Guest conversations do not accept files yet: 403 CHAT_ATTACHMENT_NOT_ALLOWED.
  • Retention: a file lives as long as its conversation and is deleted with the conversation's thread (agent removal). Closing a guest conversation does not delete its thread or files.

Errors: CHAT_ATTACHMENT_TYPE_NOT_SUPPORTED 415 (with acceptedTypes), CHAT_ATTACHMENT_TOO_LARGE 413 (with maxBytes), CHAT_ATTACHMENT_TOO_MANY 400, CHAT_ATTACHMENT_NOT_FOUND 404, CHAT_ATTACHMENT_NOT_ALLOWED 403, VALIDATION_ERROR 400 for a message with neither text nor files.

SSE ​

id: <runId>:<sequence> / event: <type> / data: <JSON> / blank line. Last-Event-ID resumes after the last delivered sequence. Omit it or send <runId>:0 to replay from the first event, including after completion. See partner event cursors for validation and conversation pagination rules. Public tool-call IDs are server-minted; grants never enter event payloads.

eventJSON data
turn{type:"turn",turnId:string,ts:ISO8601}
text{type:"text",text:string} (append delta)
tool_call{type:"tool_call",name:string,id:string,detail?:string}
tool_result{type:"tool_result",name:string,ok:boolean,error?:string}; no raw tool arguments/results
done`{type:"done",stopReason:"completed"
run_status`{type:"run_status",status:"completed"
error{type:"error",code:string}; safe execution code (for example CHAT_EXECUTION_FAILED); poll run for status

The SSE event name always matches data.type. done precedes run_status when the turn reaches its end, including max_iterations. thinking is intentionally private: internal reasoning may contain sensitive context. Optional tool detail is omitted; tool errors use the safe code TOOL_FAILED, never raw provider text. The conversation journal keeps its separate turn.started / text.delta / tool.started / tool.finished vocabulary.

Reproducible local proof ​

sh
node api/scripts/partner-chat-smoke.mjs --check
PARTNER_CHAT_SMOKE_OPT_IN=TEST_ONLY node api/scripts/partner-chat-smoke.mjs --test
# Independent, no paid provider; uses reserved workspace API port + 5 / + 6:
PARTNER_CHAT_SMOKE_OPT_IN=TEST_ONLY node api/scripts/partner-chat-smoke.mjs --fixture-mock
# Actual local Store MCP; private input JSON required:
PARTNER_CHAT_SMOKE_OPT_IN=TEST_ONLY node api/scripts/partner-chat-smoke.mjs --fixture

--test uses workspace TEST_DB_NAME, the real HTTP API, provisioning, orchestrator, model resolution, quotas, usage meter and HTTP MCP. Only completion/embedding providers are replaced. Fixture usage records are synthetic; no paid provider call occurs. Do not run concurrent suites on this database. --fixture* uses a separate _test_integration database and stops after 30 minutes.

Private input: .orca/logs/store-fixture-input.json, mode 0600, {mcpTransportUrl,staticCredential,toolName,toolInput}. Only an explicitly approved read-only tool and exact loopback HTTP/HTTPS endpoint on a registered port are accepted. HTTPS trusts a process-specific NODE_EXTRA_CA_CERTS; never disable certificate validation. The test-only transport maps canonical https://93.184.216.34/mcp to that local endpoint. This does not alter production egress.

Private output: .orca/logs/server-chat-fixture.json, mode 0600, contains fixture apiOrigin, organizationId, namespace, environment, distinct provisioningKey/chatKey, destination, staticCredential, expiresAt. Never paste this file into logs, chat, commits or browser bundles. Mock mode also supplies grantIssuerUrl/grantIssuerKey; POST {conversationId,agentId,destination} using bearer grantIssuerKey returns {grant,expiresAt}. This grant issuer is test-only, not the Store protocol.

Context recovery: after a lost bind response, GET the authoritative version before replacing an expired grant. Missing binding returns {version:0,expiresAt:null,active:false}; an expired binding retains its actual version. Revoked bindings remain denied. Mint a fresh grant and bind using that recovered version; a CAS conflict requires another GET. Never guess a version or replay an expired grant.

Set PARTNER_CHAT_FIXTURE_WORKSPACE=/absolute/initialized-worktree for an independent instance. First reserve that workspace's API port + 5 (fixture API) and + 6 (mock MCP). Set PARTNER_CHAT_FIXTURE_MCP_PORT to a different reserved port if +6 is occupied. Create the dedicated <TEST_DB_NAME>_integration database before the first fixture launch. Its own test database, private files and Redis allocation are used. Each restart creates new keys and instanceId: reload the private file and discard old instance mappings. fixtureExpiresAt records the 30-minute deadline.

Organization budgets ​

See partner quotas for organization-paid usage and HTTP 429 QUOTA_EXCEEDED / org_spend. A spent monthly budget does not reset in a minute.

Turn timings ​

GET …/conversations/:conversationId/runs/:runId returns timings in milliseconds. New runs have a pending snapshot (complete: false); the final snapshot is persisted on completion, failure or cancellation. A process crash can leave an incomplete snapshot. Historical runs without measurements return null; zero never claims an unmeasured historical duration. Poll the run until terminal before comparing durations.

queuedMs measures acceptance-to-claim time (wall clock, clamped to zero). All execution stages use a monotonic clock and exclusive accounting: nested work pauses its parent. totalMs covers queue plus execution through history persistence and turn-lock release, excluding final partner status/event delivery. Direct turns have no durable queue and use zero queue time.

FieldMeaning
admissionMs, lockMsAuthorization/admission and exclusive turn-lock acquisition
agentMs, promptMsAgent preparation and recall/prompt context
toolSetupMs, workerMsMCP discovery; worker provisioning and session attachment wait
classificationMsTier classification before the model loop
modelMs, toolMsModel requests; actual tool execution excluding worker wait
historyMs, otherMsHistory write; remaining orchestration, stream delivery and cleanup
setupMsSubtotal: admission + agent + prompt + tool setup + worker + classification
iterationsOrdered model iterations with wallMs, modelMs, firstTokenMs, toolMs, tools: [{ durationMs }] and optional usage

Add queuedMs + setupMs + lockMs + modelMs + toolMs + historyMs + otherMs to reconcile with totalMs (allow floating-point precision). Do not add setup's component fields again. Tool entries are ordered and contain no names, arguments or results. firstTokenMs is time to the first nonempty text/thinking delta; it is null on blocking calls or when no delta arrives. wallMs is the full model request span, including time spent delivering streamed events. modelMs is exclusive time awaiting the model iterator; delivery is in otherMs. firstTokenMs is a subset of wallMs. Do not add wallMs to stage totals. Provider retries/fallbacks remain inside model time. MCP tool transport/session negotiation inside a call remains tool execution, distinct from discovery.

One JSON log with event: "turn_timings", turnId and the same timings is written per turn. Iteration usage contains only inputTokens, outputTokens, and available cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens. Counters describe the provider response, not estimates; absent usage is unknown. No messages, prompts, credentials, tool arguments/results or error text are included in this summary.

Terminal run_status includes the same timings snapshot as the run object, alongside toolErrors. Historical unmeasured snapshots remain null.

Turn usage and cost ​

GET run and terminal run_status include the same usage object:

json
{"model":"claude-sonnet-5-5","inputTokens":9120,"outputTokens":410,"cacheReadTokens":0,"cacheWriteTokens":0,"costUsd":"0.033510000000","priceSide":"cost","kinds":[{"kind":"llm_tokens","costUsd":"0.033510000000"}]}

costUsd is an exact decimal string with twelve fractional digits: the sum of recorded BillingEvent.costUsdPico for the turn divided by 10^12. Parse as decimal, not binary floating point. priceSide: "cost" means Agentfy's provider cost from UsagePrice.side=cost, not your resale price. Partner credits and charge pricing are outside this contract.

Counters include every recorded token call for that turn, including tools, retries and model selection. cacheWriteTokens combines five-minute and one-hour writes. model is the latest streamed turn model, otherwise the latest turn token model (null if none was recorded). With multiple token models, models lists {model,inputTokens,outputTokens,cacheReadTokens,cacheWriteTokens,costUsd} per model. kinds: [{kind,costUsd}] always breaks down the total; it includes any non-token turn events such as runtime_seconds. Model costs cover tokens only; kind costs sum to the full total.

Usage is null while accepted/running, for historical runs, absent ledger evidence, any unpriced event, unsuccessful ledger flush, or uncertain provider execution (cancelled/interrupted or an exception). A failed run with fully recorded model responses (for example a tool refusal) can have usage. Null means unaccounted, never free. Partial amounts are deliberately not exposed as a complete bill.

Before publishing a normal terminal result, Agentfy waits for queued ledger writes, then stores the snapshot and terminal event in one transaction. GET and SSE replay always return this stored value; price changes and raw-ledger retention do not change it. A terminal null stays null: polling is not a repricing operation. Poll GET until status is terminal if the run is still active. Costs not attributed to this turn (for example a worker session torn down later) are not its turn cost. The same server_chat key and conversation ownership checks protect both reads.

Parts of a history turn ​

GET …/conversations/:conversationId/messages keeps its newest-first pagination and the existing flattened text unchanged (the API calls this field text, not content). Assistant entries additionally carry optional parts, in journal order:

json
[{"type":"text","text":"Checking the store."},{"type":"tool_call","toolCallId":"call-1","name":"store_status","status":"succeeded"},{"type":"text","text":"The store is available."}]

Adjacent text deltas become one text part; tool calls separate text parts. Text preserves journal whitespace, including separators between model steps. The last text part is the answer when the turn reaches an answer. Earlier text parts are progress notes. Interrupted or limited turns may have only progress text: use the run status to distinguish that case. There is no duplicate answer field, so the ordered journal remains the single source for this view.

Tool status is succeeded, failed, or cancelled; raw arguments, results and private thinking are never included. An unsuccessful tool does not by itself mean the entire run failed. Legacy messages without a journal omit parts; display their unchanged text as a fallback, without guessing boundaries. parts is also omitted while a journal tool lacks its terminal event. User entries omit parts.

The server fetches journal events for the assistant turn IDs in a page with one query, scoped to the conversation, independently of the number of messages. It does not split stored text or make per-message journal reads. Pagination and attachment loading are unchanged. This history projection is independent of the seven-day SSE replay window.