Store Message
Persist a message on an agent thread. Messages capture the complete agent turn — not just text, but thinking steps, tool calls, sources (citations), token usage, model, and finish reason.
POST /v1/workspaces/{workspaceId}/agent/threads/{threadId}/messagesIf you use the Server SDK, you rarely call this directly — the runtime reduces your handler's event stream into this shape and stores it for you (see how events are persisted).
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Client-generated message ID. Enables idempotent writes and draft completion (see below) |
role | string | Yes | user, assistant, or system |
content | string | No | Message text. May be omitted/null to create a draft |
thinkingSteps | array | No | Thinking/reasoning steps (JSON array, or a JSON-encoded string) |
toolCalls | array | No | Tool invocation records: [{ "id", "name", "input" }] |
sources | array | No | Citations: [{ "id", "type", "title", "url?", "snippet" }] |
tokenUsage | object | No | Token counts, e.g. { "inputTokens": 1200, "outputTokens": 240, "totalTokens": 1440 } |
model | string | No | Model identifier (e.g. claude-sonnet-4-5) |
finishReason | string | No | e.g. stop, tool_calls, length |
metadata | object | No | Arbitrary structured extras |
Nested JSON fields (thinkingSteps, toolCalls, sources, tokenUsage, metadata) accept either parsed JSON or a JSON-encoded string.
Upsert Semantics
The endpoint upserts on id:
- New ID → the message is created
- Existing ID with
content: null→ the message is treated as a draft and filled in; itscreatedAtis reset to now - Existing ID in a different thread → rejected (cross-thread message hijacking prevention)
Storing a message also bumps the thread's updatedAt, which drives the ordering in List Threads.
Example Request
curl -X POST \
'https://api.sharely.ai/v1/workspaces/{workspaceId}/agent/threads/{threadId}/messages' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"id": "e5f6a7b8-1234-4cde-9012-abcdef123456",
"role": "assistant",
"content": "Refunds are processed within 5 business days...",
"thinkingSteps": [
{ "thinkingId": "t1", "title": "Searching workspace knowledge", "status": "completed", "durationMs": 850 }
],
"toolCalls": [
{ "id": "tc1", "name": "semantic_search", "input": { "text": "refund policy" } }
],
"sources": [
{ "id": "knowledge-uuid", "type": "semantic", "title": "Refund Policy", "snippet": "Refunds are processed..." }
],
"tokenUsage": { "inputTokens": 1200, "outputTokens": 240, "totalTokens": 1440 },
"model": "claude-sonnet-4-5",
"finishReason": "stop"
}'Response (201 Created)
{
"id": "e5f6a7b8-1234-4cde-9012-abcdef123456",
"role": "assistant",
"content": "Refunds are processed within 5 business days...",
"thinkingSteps": [ { "thinkingId": "t1", "title": "Searching workspace knowledge", "status": "completed", "durationMs": 850 } ],
"toolCalls": [ { "id": "tc1", "name": "semantic_search", "input": { "text": "refund policy" } } ],
"sources": [ { "id": "knowledge-uuid", "type": "semantic", "title": "Refund Policy", "snippet": "Refunds are processed..." } ],
"tokenUsage": { "inputTokens": 1200, "outputTokens": 240, "totalTokens": 1440 },
"model": "claude-sonnet-4-5",
"finishReason": "stop",
"metadata": null,
"threadId": "550e8400-e29b-41d4-a716-446655440000",
"createdAt": "2026-06-11T10:00:09.000Z",
"updatedAt": "2026-06-11T10:00:09.000Z",
"deletedAt": null
}Errors
400- Invalidrole(must beuser,assistant, orsystem) or malformed body403- Message ID already belongs to a different thread404- Thread not found- RBAC: a token whose role does not match the thread's role cannot write to it
Related
- Threads - Create and read threads
- Handler & Wire Protocol - How the SDK produces this shape from streamed events