curl --request POST \
--url https://api.cloudhumans.com/claudia/v1/tenants/{tenant}/assistants/{id}/propose-edits \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"name": "<string>",
"description": "<string>",
"config": {},
"edits": [
{
"path": "config.system_prompt",
"old_string": "<string>",
"new_string": "<string>",
"replace_all": false
}
]
}
'import requests
url = "https://api.cloudhumans.com/claudia/v1/tenants/{tenant}/assistants/{id}/propose-edits"
payload = {
"name": "<string>",
"description": "<string>",
"config": {},
"edits": [
{
"path": "config.system_prompt",
"old_string": "<string>",
"new_string": "<string>",
"replace_all": False
}
]
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
name: '<string>',
description: '<string>',
config: {},
edits: [
{
path: 'config.system_prompt',
old_string: '<string>',
new_string: '<string>',
replace_all: false
}
]
})
};
fetch('https://api.cloudhumans.com/claudia/v1/tenants/{tenant}/assistants/{id}/propose-edits', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.cloudhumans.com/claudia/v1/tenants/{tenant}/assistants/{id}/propose-edits",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'name' => '<string>',
'description' => '<string>',
'config' => [
],
'edits' => [
[
'path' => 'config.system_prompt',
'old_string' => '<string>',
'new_string' => '<string>',
'replace_all' => false
]
]
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.cloudhumans.com/claudia/v1/tenants/{tenant}/assistants/{id}/propose-edits"
payload := strings.NewReader("{\n \"name\": \"<string>\",\n \"description\": \"<string>\",\n \"config\": {},\n \"edits\": [\n {\n \"path\": \"config.system_prompt\",\n \"old_string\": \"<string>\",\n \"new_string\": \"<string>\",\n \"replace_all\": false\n }\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.cloudhumans.com/claudia/v1/tenants/{tenant}/assistants/{id}/propose-edits")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"name\": \"<string>\",\n \"description\": \"<string>\",\n \"config\": {},\n \"edits\": [\n {\n \"path\": \"config.system_prompt\",\n \"old_string\": \"<string>\",\n \"new_string\": \"<string>\",\n \"replace_all\": false\n }\n ]\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.cloudhumans.com/claudia/v1/tenants/{tenant}/assistants/{id}/propose-edits")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"name\": \"<string>\",\n \"description\": \"<string>\",\n \"config\": {},\n \"edits\": [\n {\n \"path\": \"config.system_prompt\",\n \"old_string\": \"<string>\",\n \"new_string\": \"<string>\",\n \"replace_all\": false\n }\n ]\n}"
response = http.request(request)
puts response.read_body{}{
"error": "Forbidden: token holds no claim for the requested account"
}{
"error": "Forbidden: token holds no claim for the requested account"
}{
"error": "Forbidden: token holds no claim for the requested account"
}{
"error": "Forbidden: token holds no claim for the requested account"
}{
"error": "Forbidden: token holds no claim for the requested account"
}Propose an agent edit
First step of a safe two-step edit — nothing is written yet. Send anchored edits (each replacing ONE excerpt copied verbatim from the current text — read it first with getAssistant/readAssistantText), whole-value config/name/description keys (RFC 7386 merge patch: omit a key to keep it, null clears it, arrays are replaced whole), or both in the same call. config takes the agent’s config keys DIRECTLY — never nested under configurable. At least one of edits, config, name or description must carry a change. Returns : review the diff line by line — especially - lines — then call confirmAssistantEdits with just confirmationId and If-Match=baseUpdatedAt; no other body field is read, the server replays what it stored. Only a DRAFT accepts edits: a published agent answers 409 DRAFT_REQUIRED, naming its draft or how to create one.
curl --request POST \
--url https://api.cloudhumans.com/claudia/v1/tenants/{tenant}/assistants/{id}/propose-edits \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"name": "<string>",
"description": "<string>",
"config": {},
"edits": [
{
"path": "config.system_prompt",
"old_string": "<string>",
"new_string": "<string>",
"replace_all": false
}
]
}
'import requests
url = "https://api.cloudhumans.com/claudia/v1/tenants/{tenant}/assistants/{id}/propose-edits"
payload = {
"name": "<string>",
"description": "<string>",
"config": {},
"edits": [
{
"path": "config.system_prompt",
"old_string": "<string>",
"new_string": "<string>",
"replace_all": False
}
]
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
name: '<string>',
description: '<string>',
config: {},
edits: [
{
path: 'config.system_prompt',
old_string: '<string>',
new_string: '<string>',
replace_all: false
}
]
})
};
fetch('https://api.cloudhumans.com/claudia/v1/tenants/{tenant}/assistants/{id}/propose-edits', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.cloudhumans.com/claudia/v1/tenants/{tenant}/assistants/{id}/propose-edits",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'name' => '<string>',
'description' => '<string>',
'config' => [
],
'edits' => [
[
'path' => 'config.system_prompt',
'old_string' => '<string>',
'new_string' => '<string>',
'replace_all' => false
]
]
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.cloudhumans.com/claudia/v1/tenants/{tenant}/assistants/{id}/propose-edits"
payload := strings.NewReader("{\n \"name\": \"<string>\",\n \"description\": \"<string>\",\n \"config\": {},\n \"edits\": [\n {\n \"path\": \"config.system_prompt\",\n \"old_string\": \"<string>\",\n \"new_string\": \"<string>\",\n \"replace_all\": false\n }\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.cloudhumans.com/claudia/v1/tenants/{tenant}/assistants/{id}/propose-edits")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"name\": \"<string>\",\n \"description\": \"<string>\",\n \"config\": {},\n \"edits\": [\n {\n \"path\": \"config.system_prompt\",\n \"old_string\": \"<string>\",\n \"new_string\": \"<string>\",\n \"replace_all\": false\n }\n ]\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.cloudhumans.com/claudia/v1/tenants/{tenant}/assistants/{id}/propose-edits")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"name\": \"<string>\",\n \"description\": \"<string>\",\n \"config\": {},\n \"edits\": [\n {\n \"path\": \"config.system_prompt\",\n \"old_string\": \"<string>\",\n \"new_string\": \"<string>\",\n \"replace_all\": false\n }\n ]\n}"
response = http.request(request)
puts response.read_body{}{
"error": "Forbidden: token holds no claim for the requested account"
}{
"error": "Forbidden: token holds no claim for the requested account"
}{
"error": "Forbidden: token holds no claim for the requested account"
}{
"error": "Forbidden: token holds no claim for the requested account"
}{
"error": "Forbidden: token holds no claim for the requested account"
}The propose → confirm flow
Every write to an agent’s config, name, or description goes through two calls: a propose that previews the exact change as a diff and mints a confirmation token, and a confirm that sends the token back and applies exactly what was previewed — nothing else. This section walks through one complete round trip for an edit; proposeAssistantCreate and confirmAssistantCreate follow the same shape for creating a new agent. Draft-first, always. Both pairs only ever write to a draft agent. A published agent answersproposeAssistantEdits with 409 DRAFT_REQUIRED — you
cannot edit production directly. If the published agent already has a paired
draft, the error names it (metadata.draftAssistantId); otherwise, create one
by calling proposeAssistantCreate/confirmAssistantCreate with
metadata.productionAssistantId set to the published agent’s id (same
deployment, same tenant). That anchors the new draft to it, which is how you
stage a change to a live agent. Publishing the draft to production is a human
action in the ClaudIA app — no public endpoint does it.
1. Propose
Send the change: one or more anchorededits (each replacing a verbatim
excerpt of existing text), whole-value config/name/description keys, or
both.
POST /claudia/v1/tenants/acme/assistants/asst_9f2a.../propose-edits?role=react
{
"edits": [
{
"path": "config.tone_of_voice_prompt",
"old_string": "Always answer in a formal, corporate tone.",
"new_string": "Answer warmly and use the customer's first name when known."
}
]
}
{
"message": "THESE ARE THE CHANGES YOU ARE ABOUT TO APPLY — REVIEW the diff (git unified format) LINE BY LINE, paying special attention to lines starting with `-` (removals). If they are correct, confirm by calling confirmAssistantEdits with just confirmationId=... and If-Match=...",
"diff": "--- current\n+++ proposed\n@@ -4,7 +4,7 @@\n model_name: gpt-4o\n system_prompt: You are ClaudIA, a customer support agent for Acme.\n tenant: acme\n- tone_of_voice_prompt: Always answer in a formal, corporate tone.\n+ tone_of_voice_prompt: Answer warmly and use the customer's first name when known.\n tools: []\n deployment_role: react\n updated_at: 2026-08-20T14:03:11.482Z",
"confirmationId": "8f0c8e2a1e6b4a6e9c1a2f7d3b8e9c10",
"baseUpdatedAt": "2026-08-20T14:03:11.482Z"
}
2. Review the diff
diff is a unified diff (--- current / +++ proposed) computed over the
whole assistant, not just the field(s) you touched: both sides are
rendered as sorted key: value lines (two spaces per nesting level, long
prompts as block scalars) and diffed, so neighboring fields show up as
context lines around your change — as in the example above, where
model_name/system_prompt/tenant and tools/deployment_role/updated_at
frame the one line that actually changed. Read it line by line, paying special
attention to - lines — those are what disappears. Nothing has been written
yet: if the diff looks wrong, discard it and send a corrected proposeAssistantEdits
call instead of confirming.
3. Confirm
Send back onlyconfirmationId, plus baseUpdatedAt as the If-Match
header. No other body field is read — the server replays the exact proposal it
stored server-side.
POST /claudia/v1/tenants/acme/assistants/asst_9f2a.../confirm-edits?role=react
If-Match: 2026-08-20T14:03:11.482Z
{
"confirmationId": "8f0c8e2a1e6b4a6e9c1a2f7d3b8e9c10"
}
4. The stubbed echo
The 200 response is the persisted agent, in the same shapegetAssistant
returns — including the same stub on long prompt fields. You already authored
the new text in step 1, so it is not echoed back in full; read it again with
readAssistantText if you need it.
{
"assistant_id": "asst_9f2a...",
"deployment_role": "react",
"updated_at": "2026-08-20T14:05:47.201Z",
"config": {
"configurable": {
"tone_of_voice_prompt": "[59 chars — use readAssistantText]"
}
}
}
confirmationId no longer resolves — the proposal’s TTL expired — the 404
names PROPOSAL_NOT_FOUND and coaches re-running proposeAssistantEdits with
the same body. Unlike the create pair, an edits confirm is not consumed on
success: retrying the exact same confirm after it already applied does not
404. Its own write moved updated_at, so the retry’s If-Match is now stale
against the current agent and it fails the same way any other stale confirm
does — 412 STALE_PRECONDITION. If you see that, re-read the agent with
getAssistant first: the change may already be applied, in which case there
is nothing left to do; only propose again if it genuinely is not there.Authorizations
Bearer authentication header of the form Bearer <token>, where <token> is your auth token.
Path Parameters
Tenant whose agents you are managing. Discover the tenants your credentials cover with listMyClaudiaProjects. A tenant your credentials do not cover is indistinguishable from one that does not exist.
Assistant to operate on, as returned by listAssistants (assistant_id field).
Query Parameters
Deployment the assistant lives in: supervisor (orchestrator agents) or react (specialist agents), plus qna for knowledge-base Q&A agents. Read them with that role; they are CREATED through proposeAssistantCreate as deployment: react with graphId: qna_agent, and the platform derives the qna role from the graph. listAssistants returns it as deployment_role on each row — pass that value back here verbatim. A qna agent follows the same draft rule as any other: only a draft accepts writes, and a published one answers 409 naming its draft.
supervisor, react, qna Body
The change to apply: anchored edits, whole-value keys, or both.
Anchored text edits and/or whole-value patch keys; at least one of edits, config, name or description must carry a change.
New display name. Omit to keep it; it cannot be null or blank.
This assistant's routing description. Omit to keep it; a JSON null clears it.
Whole-value config edit: the agent's config keys DIRECTLY (flat, no configurable wrapper) for everything you are NOT editing with anchors (mcp_servers, agents, reasoning_effort, …). A field targeted by edits must NOT also appear here. Send it as a JSON OBJECT — never as a JSON-encoded string.
The anchored replacements to apply, in order — each one sees the previous one's result. Every entry must target a text field (config.system_prompt, config.tone_of_voice_prompt, description) and match an excerpt that occurs exactly once. Omit it when the change is whole-value only.
Show child attributes
Show child attributes
Response
The confirmation contract: message (review + confirm instructions), diff (git unified format), confirmationId and baseUpdatedAt — send the latter back as If-Match on confirmAssistantEdits.
The response is of type object.