Skip to content

Tutorial: act from your own runtime

An agent you run yourself, on your own servers and with whatever model you choose, can act through Locus without Synapse. Locus decides each action: whether the agent’s grant covers the tool, what the active policy says, whether a person must approve, and what the budget allows. This tutorial sets that up for one read-only HTTPS tool and has the agent call it once.

The calls and request shapes below are checked against the API contract and both clients.

From your own runtime, a tool is called only in a Live environment, under a budget for the live usage class. In Test, authorizeAction answers and submitAction records the action, but the tool is never called: under a simulation budget the submission is refused when it would run, and under Test Compute the policy denies it with reason test_compute_effect. A Live budget is funded from credits, so live_funding on listEnvironments must be true; see paid compute.

Tools that charge a price or use a credential Katara holds answer 409 managed_run_required: only a managed run can use them. This tutorial uses a free tool with no credential.

Everything except the agent’s own calls takes a person signed in to the portal as an owner or admin. One person does all of it: the agent’s actions are recorded as requested by whoever issued its grant, so that person’s key can read them and that person cannot approve them.

StepWhoWhere
Create a service account and an external agent identityPersonOrganization, then Locus → Agent Identities → Register Agent Identity
Register and verify the tool’s endpoint, publish the tool and enable itPersonLocus → Tools
Share the tool’s files with the agentPersonSynapse → Storage
Activate a policyPersonLocus → Policies
Fund a Live budget and issue the agent’s grantPersonLocus → Budgets & Grants
Create the agent’s API keyPersonAPI Keys
Store the action’s argumentsAgentbeginContentUpload … completeContentUpload
Approve the argumentsPersonSynapse → Storage
Ask, submit and read the resultAgentauthorizeAction, submitAction, getAction

Work in the organization’s Live environment throughout.

  1. The agent. On the Organization page, create a service account with the builder role. Then register an agent identity for it under Locus → Agent Identities, with execution External. An external identity never gets a managed worker; your runtime does the work.

  2. The tool. You need an HTTPS API on a public host and an OpenAPI document for it with one GET operation that has no security requirement. Under Locus → Tools → Tool Connections, register the API’s server URL. Katara answers with an ownership document and the address to serve it at, https://<your host>/.well-known/katara-tools/<challenge id>. Serve that JSON there, verify the connection within 24 hours, and keep serving it: Katara reads it again when you publish, when you enable and at every action. Upload the OpenAPI document under Synapse → Storage and approve it. Import from OpenAPI then prepares the operation, stores its schemas for you to approve, and publishes it as a free tool. Select Enable Version on the tool’s page and note the tool ID.

  3. The tool’s content. The agent reads what the tool version pins, its description, its two schemas and the OpenAPI document, with its own access at each action. Under Synapse → Storage, share each of those files with the agent’s service account as a reader.

  4. A policy. An environment with no active policy refuses every action with 409 conflict. Under Locus → Policies, create one that allows the tool kind and your API’s origin, for example https://api.example.com, with no approval effects and detector failures denied. A policy always has at least one approval stage naming a team, even when no action needs approval; create the team on the Organization page first. Review the policy, then activate it.

  5. A budget and a grant. Under Locus → Budgets & Grants → Fund a Budget, fund a Live budget with a tool cap above zero; funding holds the amount from your credits, and the budget’s consent lasts thirty days unless you choose fewer. Then choose Issue Agent Grant on the same page with that budget selected, pick the agent identity, the purpose Call models and tools and the tool. The grant is for Locus (katara:locus) and includes locus:actions:execute on the tool; it lasts thirty days unless you shorten it, and never past the budget’s end. Note the budget and grant IDs.

  6. The agent’s key. Under API Keys, create a Live key for the agent’s service account with these scopes:

    • locus:actions:execute and locus:tools:read to ask about and submit actions
    • synapse:memory:write to store the arguments, and synapse:memory:read because the arguments are screened under it
    • locus:actions:read to read the action back

    The secret is shown once. Give it to the agent as KATARA_AGENT_KEY.

The agent needs the TypeScript client @katara/platform or the Python client katara-platform; neither is on npm or PyPI yet, so use the copy supplied for your environment. Set KATARA_AGENT_KEY, KATARA_ORG_ID, KATARA_ENVIRONMENT_ID (the Live environment), KATARA_TOOL_ID, KATARA_GRANT_ID and KATARA_BUDGET_ID.

An action’s arguments are content, like any other input: the agent stores them and a person approves them before any action uses them. Approved arguments can be used again by later actions. For a REST tool the arguments object has the keys the tool’s input schema allows, path, query, header and body; the example’s operation takes one query parameter, q.

Save this as act.mts and run it with a TypeScript runner, for example npx tsx act.mts. It writes action.json, which holds the arguments and the action it is about to send. The action’s id is the request’s idempotency key: sending the same id with the same body again never calls the tool twice.

import { createHash, randomUUID } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { ProductClient, PlatformError, uploadContent, type ActionSubmitRequest, type ProductContentRef, type ProductScope } from '@katara/platform';
const client = new ProductClient({ baseUrl: 'https://api.katara.com', credential: () => process.env.KATARA_AGENT_KEY ?? null });
const scope: ProductScope = { org_id: process.env.KATARA_ORG_ID!, environment_id: process.env.KATARA_ENVIRONMENT_ID! };
const file = 'action.json';
const state: { upload?: { object_id: string; begin: string; complete: string }; arguments?: ProductContentRef; request?: ActionSubmitRequest } =
existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
const save = () => writeFileSync(file, JSON.stringify(state, null, 2));
function stop(message: string): never { console.log(message); process.exit(0); }
async function storeArguments(value: unknown): Promise<ProductContentRef> {
const bytes = new TextEncoder().encode(JSON.stringify(value));
const store = (await client.listContentStores(scope)).data.find(s => s.state === 'active');
if (!store) stop('The environment has no content store.');
state.upload ??= { object_id: randomUUID(), begin: `act-${randomUUID()}`, complete: `act-${randomUUID()}` };
save();
const started = await client.beginContentUpload(scope, {
store_id: store.id, object_id: state.upload.object_id, previous_version: 0, name: 'arguments.json',
integrity: 'sha256:' + createHash('sha256').update(bytes).digest('hex'), media_type: 'application/json', size_bytes: bytes.length,
}, state.upload.begin);
const history = await client.contentHistory(scope, started.content);
if (history.data.find(v => v.content.version === started.content.version)?.state === 'uploading') {
await uploadContent(await client.issueContentUpload(scope, started.content), started.content, bytes);
}
await client.completeContentUpload(scope, started.content, state.upload.complete);
return started.content;
}
async function main() {
if (!state.arguments) { state.arguments = await storeArguments({ query: { q: 'katara' } }); save(); }
const history = await client.contentHistory(scope, state.arguments);
if (history.data.find(v => v.content.version === state.arguments!.version)?.state !== 'approved') {
stop('Ask a person to approve arguments.json under Synapse → Storage, then run this again.');
}
// The enabled version of the tool, named exactly.
const tool = await client.getToolState(scope, process.env.KATARA_TOOL_ID!);
if (!tool.activation?.enabled) stop('The tool has no enabled version.');
const signed = await client.getToolVersion(scope, tool.activation.version_id);
// Saved before it is sent, so a lost reply is recovered by sending it again unchanged.
state.request ??= {
id: randomUUID(), tool: { id: signed.version.id, version: signed.version.revision, integrity: signed.digest },
arguments: state.arguments, grant_id: process.env.KATARA_GRANT_ID!, budget_id: process.env.KATARA_BUDGET_ID!,
expires_at: new Date(Date.now() + 3600_000).toISOString(),
};
save();
const { id, ...question } = state.request;
const advice = await client.authorizeAction(scope, question);
console.log('authorize:', advice.outcome, advice.reasons.join(', '));
if (advice.outcome === 'deny') { delete state.request; save(); return; }
const result = await client.submitAction(scope, state.request);
const action = result.action;
if (action.state === 'pending') stop('Waiting for a person to approve the action. Run this again after they decide.');
if (action.state === 'claimed' && result.outcome === 'reported_success') console.log('output:', JSON.stringify(result.output));
else if (action.state === 'claimed' && result.outcome === 'unknown') console.log('The call may have taken effect. Check the API before doing anything else.');
else if (action.state === 'claimed') console.log('This action already ran. Its output is not returned again.');
else console.log(action.state, action.reasons.join(', '));
const record = await client.getAction(scope, id);
console.log('recorded:', record.state, record.recorded_decision.outcome, 'claimed:', record.execution_claimed);
delete state.request;
save();
}
main().catch(error => {
if (error instanceof PlatformError) console.error(error.status, error.code, error.message, 'request', error.requestId);
else console.error(error);
process.exit(1);
});

The same in Python, as act.py:

import hashlib, json, os, sys, uuid
from datetime import datetime, timedelta, timezone
from katara_platform import PlatformError, ProductClient, upload_content
client = ProductClient('https://api.katara.com', lambda: os.environ.get('KATARA_AGENT_KEY'))
org, env = os.environ['KATARA_ORG_ID'], os.environ['KATARA_ENVIRONMENT_ID']
FILE = 'action.json'
state = json.load(open(FILE)) if os.path.exists(FILE) else {}
def save():
with open(FILE, 'w') as f:
json.dump(state, f, indent=2)
def stop(message):
print(message)
sys.exit(0)
def version_state(ref):
return next((v['state'] for v in client.content_history(org, env, ref)['data'] if v['content']['version'] == ref['version']), None)
def store_arguments(value):
data = json.dumps(value).encode()
store = next((s for s in client.list_content_stores(org, env)['data'] if s['state'] == 'active'), None)
if store is None:
stop('The environment has no content store.')
state.setdefault('upload', {'object_id': str(uuid.uuid4()), 'begin': 'act-' + str(uuid.uuid4()), 'complete': 'act-' + str(uuid.uuid4())})
save()
started = client.begin_content_upload(org, env, {
'store_id': store['id'], 'object_id': state['upload']['object_id'], 'previous_version': 0, 'name': 'arguments.json',
'integrity': 'sha256:' + hashlib.sha256(data).hexdigest(), 'media_type': 'application/json', 'size_bytes': len(data),
}, state['upload']['begin'])
if version_state(started['content']) == 'uploading':
upload_content(client.issue_content_upload(org, env, started['content']), started['content'], data)
client.complete_content_upload(org, env, started['content'], state['upload']['complete'])
return started['content']
def main():
if 'arguments' not in state:
state['arguments'] = store_arguments({'query': {'q': 'katara'}})
save()
if version_state(state['arguments']) != 'approved':
stop('Ask a person to approve arguments.json under Synapse → Storage, then run this again.')
tool = client.get_tool_state(org, env, os.environ['KATARA_TOOL_ID'])
if not (tool.get('activation') or {}).get('enabled'):
stop('The tool has no enabled version.')
signed = client.get_tool_version(org, env, tool['activation']['version_id'])
if 'request' not in state:
state['request'] = {
'id': str(uuid.uuid4()),
'tool': {'id': signed['version']['id'], 'version': signed['version']['revision'], 'integrity': signed['digest']},
'arguments': state['arguments'], 'grant_id': os.environ['KATARA_GRANT_ID'], 'budget_id': os.environ['KATARA_BUDGET_ID'],
'expires_at': (datetime.now(timezone.utc) + timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M:%SZ'),
}
save()
request = state['request']
question = {k: v for k, v in request.items() if k != 'id'}
advice = client.authorize_action(org, env, question)
print('authorize:', advice['outcome'], ', '.join(advice['reasons']))
if advice['outcome'] == 'deny':
del state['request']
save()
return
result = client.submit_action(org, env, request)
action = result['action']
if action['state'] == 'pending':
stop('Waiting for a person to approve the action. Run this again after they decide.')
if action['state'] == 'claimed' and result.get('outcome') == 'reported_success':
print('output:', json.dumps(result.get('output')))
elif action['state'] == 'claimed' and result.get('outcome') == 'unknown':
print('The call may have taken effect. Check the API before doing anything else.')
elif action['state'] == 'claimed':
print('This action already ran. Its output is not returned again.')
else:
print(action['state'], ', '.join(action['reasons']))
record = client.get_action(org, env, request['id'])
print('recorded:', record['state'], record['recorded_decision']['outcome'], 'claimed:', record['execution_claimed'])
del state['request']
save()
try:
main()
except PlatformError as error:
print(error.status, error.code, error.message, 'request', error.request_id, file=sys.stderr)
sys.exit(1)

The first run stores the arguments and stops until a person approves arguments.json under Synapse → Storage. The next run asks, submits and prints the tool’s answer:

authorize: allow
output: {…}
recorded: claimed allow claimed: true

output is the API’s answer in the shape the tool’s output schema describes.

authorizeAction is advice: nothing is recorded and it is not permission to act. submitAction builds the action from the published tool version, so the destination, effect and cost come from the catalog and never from the request. It checks the grant, the active policy and the budget, and an action that needs nothing more runs at once.

submitAction answersMeaning
200, state claimed, outcome reported_successThe tool answered. output carries its answer; it is not stored, so this reply is the only copy
200, state claimed, outcome unknownThe call may have taken effect. Do not submit another action to repeat it; an owner or admin records what happened
200, state claimed, no outcomeThe same id already ran. The tool is not called again and its output is not returned again
202, state pendingA person must approve first. Submit the same request again after they decide
200, state denied or expiredThe policy refused it, or it waited past expires_at. reasons says why
403 permission_deniedThe key, grant, budget or tool does not allow it, the arguments are not approved, a reviewer denied it, or the environment is not Live
409 conflictThe id was used with a different body, the environment has no active policy, or something the action depends on changed
409 managed_run_requiredThe tool has a price or a Katara-held credential

Each action is one tool unit, held against the budget’s tool cap when it is claimed; what a governed action costs has the rest. getAction / get_action and listActions / list_actions return the recorded decision, state and execution_claimed, and for an action that ran, the outcome its call reported; Locus → Actions shows the same. An unknown outcome also carries reconciliation: awaiting until someone records what happened, then recorded.

If submitAction returns no reply, send the same request again: the same id and an identical body. An action that was not recorded is recorded now; one that was recorded and not yet run runs now; one that already ran answers claimed without calling the tool. The script does this, because it saved the request before sending it. A new id is a new action and calls the tool again.

An action that ran but whose reply was lost cannot give its output back. For a read-only tool, submit a new action under a new id. For a tool that writes, find out from the API itself what happened before doing anything else. The same applies to an unknown outcome. Once you know, the owner or an admin records it under Locus → Actions, which settles the action’s tool unit; see Record what an unknown action did. A tool that writes declares in reconciliation how an unknown result is settled; see Say how an unknown result is settled.

If the policy names the tool’s effect in its approval effects, for example a tool that writes, submitAction answers 202 with state pending. A member of the stage’s team approves or denies it under Locus → Approvals, signed in to the portal. The person who issued the agent’s grant cannot approve its actions, so the team needs someone else. Running the script again resubmits the same request: it runs once approved, is refused with 403 if a reviewer denied it, and answers expired if nobody decided in time. Decide an action covers the reviewer’s side.

The same agent can ask Synapse for the passages that best answer a question, packed for its model, with assembleContext / assemble_context. It needs a separate grant for Synapse (katara:synapse) with synapse:memory:read on the collections, and its key needs synapse:memory:read. See Assemble context for your own runtime.