Agent SOAT Tools and Preset Parameters
Give an agent access to platform documents with builtin tools, and lock a tool to one document ID with preset parameters. You create a public and a private note, a restricted user alice, three builtin tools (docs_list-documents, docs_get-document, docs_update-document with the public document's ID preset) and an agent, then verify the agent updates the right document and alice's policy blocks the private one.
Prerequisites
- SOAT running locally (Quick Start).
- Ollama at
http://ollama:11434withqwen2.5:0.5bpulled (ollama pull qwen2.5:0.5b). - CLI, SDK, or curl; server at
http://localhost:5047.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
import { SoatClient } from '@soat/sdk';
export SOAT_BASE_URL=http://localhost:5047
Step 1 — Log in as admin
Admin bypasses policy evaluation — see IAM — Authentication.
- CLI
- SDK
- curl
soat login-user --username admin --password Admin1234!
soat configure # paste the token when prompted
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: session } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: session!.token,
});
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
Step 2 — Create a project
Every resource lives inside a project.
- CLI
- SDK
- curl
PROJECT_ID=$(soat create-project --name "Notes Project" | jq -r '.id')
echo "Project: $PROJECT_ID"
const { data: project } = await adminSoat.projects.createProject({
body: { name: 'Notes Project' },
});
const projectId = project!.id;
PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Notes Project"}' | jq -r '.id')
echo "Project: $PROJECT_ID"
Step 3 — Create an Ollama AI provider
A local Ollama AI provider. For other providers see Connect Third-Party LLMs.
- CLI
- SDK
- curl
PROVIDER_ID=$(soat create-ai-provider \
--project-id "$PROJECT_ID" \
--name "Ollama" \
--provider "ollama" \
--default-model "qwen2.5:0.5b" | jq -r '.id')
echo "Provider: $PROVIDER_ID"
const { data: provider } = await adminSoat.aiProviders.createAiProvider({
body: {
project_id: projectId,
name: 'Ollama',
provider: 'ollama',
default_model: 'qwen2.5:0.5b',
},
});
const providerId = provider!.id;
PROVIDER_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/ai-providers" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Ollama\",\"provider\":\"ollama\",\"default_model\":\"qwen2.5:0.5b\"}" | jq -r '.id')
echo "Provider: $PROVIDER_ID"
Step 4 — Create documents
Two documents: a public note the agent updates and a private note it must not touch.
- CLI
- SDK
- curl
PUBLIC_DOC_ID=$(soat create-document \
--project-id "$PROJECT_ID" \
--title "Public Note" \
--content "Initial public content." \
--path "/notes/public/note.txt" | jq -r '.id')
echo "Public doc: $PUBLIC_DOC_ID"
PRIVATE_DOC_ID=$(soat create-document \
--project-id "$PROJECT_ID" \
--title "Private Note" \
--content "Confidential information." \
--path "/notes/private/note.txt" | jq -r '.id')
echo "Private doc: $PRIVATE_DOC_ID"
const { data: publicDoc } = await adminSoat.documents.createDocument({
body: {
project_id: projectId,
title: 'Public Note',
content: 'Initial public content.',
path: '/notes/public/note.txt',
},
});
const publicDocId = publicDoc!.id;
const { data: privateDoc } = await adminSoat.documents.createDocument({
body: {
project_id: projectId,
title: 'Private Note',
content: 'Confidential information.',
path: '/notes/private/note.txt',
},
});
const privateDocId = privateDoc!.id;
PUBLIC_DOC_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/documents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"project_id\": \"$PROJECT_ID\",
\"title\": \"Public Note\",
\"content\": \"Initial public content.\",
\"path\": \"/notes/public/note.txt\"
}" | jq -r '.id')
echo "Public doc: $PUBLIC_DOC_ID"
PRIVATE_DOC_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/documents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"project_id\": \"$PROJECT_ID\",
\"title\": \"Private Note\",
\"content\": \"Confidential information.\",
\"path\": \"/notes/private/note.txt\"
}" | jq -r '.id')
echo "Private doc: $PRIVATE_DOC_ID"
Step 5 — Create user alice with a restricted policy
Alice may run agent generations and access documents under /notes/public/* only. See Users, Policies and IAM — SRNs.
- CLI
- SDK
- curl
ALICE_ID=$(soat create-user --username alice-agent-soat-tools --password Alice1234! | jq -r '.id')
echo "Alice: $ALICE_ID"
POLICY_ID=$(soat create-policy \
--name "alice-agent-soat-tools-notes-policy" \
--document '{
"statement": [
{
"effect": "Allow",
"action": ["agents:CreateAgentGeneration"]
},
{
"effect": "Allow",
"action": ["documents:ListDocuments"]
},
{
"effect": "Allow",
"action": ["documents:GetDocument", "documents:UpdateDocument"],
"resource": ["srn:'"$PROJECT_ID"':document:/notes/public/*"]
}
]
}' | jq -r '.id')
soat attach-user-policies \
--user-id "$ALICE_ID" \
--policy-ids '["'"$POLICY_ID"'"]'
const { data: alice } = await adminSoat.users.createUser({
body: { username: 'alice-agent-soat-tools', password: 'Alice1234!' },
});
const aliceId = alice!.id;
const { data: policy } = await adminSoat.policies.createPolicy({
body: {
name: 'alice-agent-soat-tools-notes-policy',
document: {
statement: [
{ effect: 'Allow', action: ['agents:CreateAgentGeneration'] },
{ effect: 'Allow', action: ['documents:ListDocuments'] },
{
effect: 'Allow',
action: ['documents:GetDocument', 'documents:UpdateDocument'],
resource: [`srn:${projectId}:document:/notes/public/*`],
},
],
},
},
});
await adminSoat.users.attachUserPolicies({
path: { user_id: aliceId },
body: { policy_ids: [policy!.id] },
});
ALICE_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"alice-agent-soat-tools","password":"Alice1234!"}' | jq -r '.id')
echo "Alice: $ALICE_ID"
POLICY_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/policies" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"alice-agent-soat-tools-notes-policy\",
\"policy\": {
\"statement\": [
{\"effect\": \"Allow\", \"action\": [\"agents:CreateAgentGeneration\"]},
{\"effect\": \"Allow\", \"action\": [\"documents:ListDocuments\"]},
{
\"effect\": \"Allow\",
\"action\": [\"documents:GetDocument\", \"documents:UpdateDocument\"],
\"resource\": [\"srn:$PROJECT_ID:document:/notes/public/*\"]
}
]
}
}" | jq -r '.id')
curl -s -X POST "$SOAT_BASE_URL/api/v1/policies/attach-user" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"user_id\": \"$ALICE_ID\", \"policy_id\": \"$POLICY_ID\"}"
Step 6 — Create builtin tools
Three tools. docs-write carries preset_parameters with the public document's ID, keyed by the snake_case wire name (document_id); the model never sees the field and the server injects it at call time.
- CLI
- SDK
- curl
# Tool 1 — list documents
LIST_TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name "docs" \
--type builtin \
--actions '["list-documents"]' | jq -r '.id')
# Tool 2 — read any document (model supplies document_id)
READ_TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name "docs" \
--type builtin \
--actions '["get-document"]' | jq -r '.id')
# Tool 3 — update the public document (document_id is preset)
WRITE_TOOL_ID=$(soat create-tool \
--project-id "$PROJECT_ID" \
--name "docs" \
--type builtin \
--actions '["update-document"]' \
--preset-parameters '{"document_id": "'"$PUBLIC_DOC_ID"'"}' | jq -r '.id')
echo "List: $LIST_TOOL_ID"
echo "Read: $READ_TOOL_ID"
echo "Write: $WRITE_TOOL_ID"
const { data: listTool } = await adminSoat.tools.createTool({
body: {
project_id: projectId,
name: 'docs',
type: 'soat',
actions: ['list-documents'],
},
});
const { data: readTool } = await adminSoat.tools.createTool({
body: {
project_id: projectId,
name: 'docs',
type: 'soat',
actions: ['get-document'],
},
});
// document_id is preset — the model never sees this parameter
const { data: writeTool } = await adminSoat.tools.createTool({
body: {
project_id: projectId,
name: 'docs',
type: 'soat',
actions: ['update-document'],
preset_parameters: { document_id: publicDocId },
},
});
const listToolId = listTool!.id;
const readToolId = readTool!.id;
const writeToolId = writeTool!.id;
LIST_TOOL_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/agents/tools" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"project_id\": \"$PROJECT_ID\",
\"name\": \"docs\",
\"type\": \"soat\",
\"actions\": [\"list-documents\"]
}" | jq -r '.id')
READ_TOOL_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/agents/tools" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"project_id\": \"$PROJECT_ID\",
\"name\": \"docs\",
\"type\": \"soat\",
\"actions\": [\"get-document\"]
}" | jq -r '.id')
WRITE_TOOL_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/agents/tools" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"project_id\": \"$PROJECT_ID\",
\"name\": \"docs\",
\"type\": \"soat\",
\"actions\": [\"update-document\"],
\"preset_parameters\": {\"document_id\": \"$PUBLIC_DOC_ID\"}
}" | jq -r '.id')
echo "List: $LIST_TOOL_ID"
echo "Read: $READ_TOOL_ID"
echo "Write: $WRITE_TOOL_ID"
Tool names the model sees:
| Tool name | Action | document_id visible to model? |
|---|---|---|
docs_list-documents | list documents | N/A |
docs_get-document | read a document | yes — model supplies it |
docs_update-document | update a document | no — injected from preset_parameters |
Step 7 — Create the agent
Create the agent with all three tools attached.
- CLI
- SDK
- curl
AGENT_ID=$(soat create-agent \
--project-id "$PROJECT_ID" \
--ai-provider-id "$PROVIDER_ID" \
--name "Notes Agent" \
--instructions "You are a note-taking assistant. Use your tools to list, read, and update documents." \
--tool-bindings "[{\"tool_id\":\"$LIST_TOOL_ID\"},{\"tool_id\":\"$READ_TOOL_ID\"},{\"tool_id\":\"$WRITE_TOOL_ID\"}]" | jq -r '.id')
echo "Agent: $AGENT_ID"
const { data: agent } = await adminSoat.agents.createAgent({
body: {
project_id: projectId,
ai_provider_id: providerId,
name: 'Notes Agent',
instructions:
'You are a note-taking assistant. Use your tools to list, read, and update documents.',
tool_bindings: [{ tool_id: listToolId }, { tool_id: readToolId }, { tool_id: writeToolId }],
},
});
const agentId = agent!.id;
AGENT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/agents" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"project_id\": \"$PROJECT_ID\",
\"ai_provider_id\": \"$PROVIDER_ID\",
\"name\": \"Notes Agent\",
\"instructions\": \"You are a note-taking assistant. Use your tools to list, read, and update documents.\",
\"tool_bindings\": [{ \"tool_id\": \"$LIST_TOOL_ID\" }, { \"tool_id\": \"$READ_TOOL_ID\" }, { \"tool_id\": \"$WRITE_TOOL_ID\" }]
}" | jq -r '.id')
echo "Agent: $AGENT_ID"
Step 8 — Log in as alice and run a generation
Alice asks the agent to update the public note via a session. The agent calls docs_update-document without the document ID; the server injects it from preset_parameters.
- CLI
- SDK
- curl
# Log in as alice
ALICE_TOKEN=$(soat login-user --username alice-agent-soat-tools --password Alice1234! | jq -r '.token')
# Run the generation
RESULT=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/agents/$AGENT_ID/generate?wait=true" \
-H "Authorization: Bearer $ALICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{
"role": "user",
"content": "Please update the public note with the content: Updated by the agent."
}
]
}')
echo "$RESULT" | jq '.'
// Log in as alice
const aliceSoat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: aliceSession } = await aliceSoat.users.loginUser({
body: { username: 'alice-agent-soat-tools', password: 'Alice1234!' },
});
const aliceClient = new SoatClient({
baseUrl: 'http://localhost:5047',
token: aliceSession!.token,
});
// Run the generation
const { data: generation } = await aliceClient.agents.createAgentGeneration({
path: { agent_id: agentId },
query: { wait: true },
body: {
messages: [
{
role: 'user',
content:
'Please update the public note with the content: Updated by the agent.',
},
],
},
});
console.log('Status:', generation!.status);
console.log('Result:', generation!.result);
ALICE_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"alice-agent-soat-tools","password":"Alice1234!"}' | jq -r '.token')
curl -s -X POST "$SOAT_BASE_URL/api/v1/agents/$AGENT_ID/generate?wait=true" \
-H "Authorization: Bearer $ALICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{
"role": "user",
"content": "Please update the public note with the content: Updated by the agent."
}
]
}' | jq '.'
Reuse a tool result as the generation input
A generation can start with message content of type tool_output: the server executes the referenced tool, applies output_path, and feeds the extracted value to the model as the user message.
messages carries user and assistant turns only; steer the agent with its instructions. A role: "system" entry is rejected with 400 SYSTEM_MESSAGE_NOT_ALLOWED.
- CLI
- SDK
- curl
TOOL_OUTPUT_RESULT=$(soat create-agent-generation --wait true \
--agent-id "$AGENT_ID" \
--messages '[
{
"role":"user",
"content": {
"type": "tool_output",
"tool_id": "'"$READ_TOOL_ID"'",
"action": "get-document",
"input": {"document_id": "'"$PUBLIC_DOC_ID"'"},
"output_path": ".content"
}
}
]')
echo "$TOOL_OUTPUT_RESULT" | jq '.'
const { data: toolOutputGeneration } =
await aliceClient.agents.createAgentGeneration({
path: { agent_id: agentId },
query: { wait: true },
body: {
messages: [
{
role: 'user',
content: {
type: 'tool_output',
tool_id: readToolId,
action: 'get-document',
input: { document_id: publicDocId },
output_path: '.content',
},
},
],
},
});
console.log('Status:', toolOutputGeneration!.status);
console.log('Result:', toolOutputGeneration!.result);
curl -s -X POST "$SOAT_BASE_URL/api/v1/agents/$AGENT_ID/generate?wait=true" \
-H "Authorization: Bearer $ALICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{
"role": "user",
"content": {
"type": "tool_output",
"tool_id": "'"$READ_TOOL_ID"'",
"action": "get-document",
"input": {"document_id": "'"$PUBLIC_DOC_ID"'"},
"output_path": ".content"
}
}
]
}' | jq '.'
Step 9 — Verify the update and permissions
Confirm the public document was updated and the private one is blocked by alice's IAM policy.
Confirm the public document was updated
- CLI
- SDK
- curl
soat get-document --document-id "$PUBLIC_DOC_ID" | jq '.content'
# Expected: "Updated by the agent."
const { data: updated } = await adminSoat.documents.getDocument({
path: { document_id: publicDocId },
});
console.log('Content:', updated!.content);
// Expected: "Updated by the agent."
curl -s "$SOAT_BASE_URL/api/v1/documents/$PUBLIC_DOC_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '.content'
# Expected: "Updated by the agent."
Confirm alice cannot read the private document
- CLI
- SDK
- curl
SOAT_TOKEN="$ALICE_TOKEN" \
soat get-document --document-id "$PRIVATE_DOC_ID" | jq '.'
# Expected: 403 Forbidden
const { error } = await aliceClient.documents.getDocument({
path: { document_id: privateDocId },
});
console.log('Error:', error);
// Expected: 403 Forbidden
curl -s "$SOAT_BASE_URL/api/v1/documents/$PRIVATE_DOC_ID" \
-H "Authorization: Bearer $ALICE_TOKEN" | jq '.'
# Expected: 403 Forbidden
Asking the agent to update the private note yields a 403 on docs_get-document, which the agent reports back.
Step 10 — Call a tool directly via REST
Any non-client tool can be invoked without an agent or generation. The list-documents tool from step 6 has project_id preset, so the body needs no parameters.
- CLI
- SDK
- curl
soat call-tool \
--tool-id "$LIST_TOOL_ID" \
--action "list-documents"
const { data: result } = await aliceSoat.tools.callTool({
params: { path: { tool_id: listToolId } },
body: {
action: 'list-documents',
},
});
console.log(result);
curl -s -X POST "$SOAT_BASE_URL/api/v1/tools/$LIST_TOOL_ID/call" \
-H "Authorization: Bearer $ALICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"action": "list-documents"}'
preset_parameters are merged before dispatch and alice's IAM policy still applies. For http tools omit action and pass parameters in input; for mcp tools set action to the MCP tool name.
What happened
docs-writestored{ "document_id": "<public doc id>" }aspreset_parameters; the server strippeddocument_idfrom the schema shown to the model (preset keys are the tool's snake_case parameter names).- The model saw
docs_update-documentaccepting onlycontent,title,path,metadataandtags, so it could not supply a wrong ID. - On call, the server merged
document_idback in before dispatchingPATCH /api/v1/documents/{document_id}. - The request ran under alice's JWT; her policy allows
documents:UpdateDocumenton/notes/public/note.txt, and paths outside/notes/public/*return 403.
Next steps
- Add more actions (e.g.
search-knowledge) to the tools. - Step rules — force a specific tool call first.
- Boundary policies — cap actions per agent, independent of the caller.
- Agents reference — all soat actions and options.