# Tutorial: act from your own runtime > Let an agent you host call one tool through Locus, under a grant, a budget and a policy, with the TypeScript or Python client. Section: Synapse and Locus. Web page: https://docs.katara.com/products/tutorial-external-agent/ 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. ## Live only 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](https://docs.katara.com/products/availability.txt#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. ## Who does what 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. | Step | Who | Where | | --- | --- | --- | | Create a service account and an external agent identity | Person | **Organization**, then **Locus → Agent Identities → Register Agent Identity** | | Register and verify the tool's endpoint, publish the tool and enable it | Person | **Locus → Tools** | | Share the tool's files with the agent | Person | **Synapse → Storage** | | Activate a policy | Person | **Locus → Policies** | | Fund a Live budget and issue the agent's grant | Person | **Locus → Budgets & Grants** | | Create the agent's API key | Person | **API Keys** | | Store the action's arguments | Agent | `beginContentUpload` … `completeContentUpload` | | Approve the arguments | Person | **Synapse → Storage** | | Ask, submit and read the result | Agent | `authorizeAction`, `submitAction`, `getAction` | ## Set up, as a person 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:///.well-known/katara-tools/`. 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's script 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. ```ts 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 { 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`: ```python 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) ``` ## What you see 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: ```text 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` answers | Meaning | | --- | --- | | `200`, state `claimed`, outcome `reported_success` | The tool answered. `output` carries its answer; it is not stored, so this reply is the only copy | | `200`, state `claimed`, outcome `unknown` | The call may have taken effect. Do not submit another action to repeat it; an owner or admin records what happened | | `200`, state `claimed`, no outcome | The same `id` already ran. The tool is not called again and its output is not returned again | | `202`, state `pending` | A person must approve first. Submit the same request again after they decide | | `200`, state `denied` or `expired` | The policy refused it, or it waited past `expires_at`. `reasons` says why | | `403` `permission_denied` | The 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` `conflict` | The `id` was used with a different body, the environment has no active policy, or something the action depends on changed | | `409` `managed_run_required` | The 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](https://docs.katara.com/products/tools-and-approvals.txt#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`. ## Recover from an uncertain response 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](https://docs.katara.com/products/tools-and-approvals.txt#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](https://docs.katara.com/products/tools-and-approvals.txt#say-how-an-unknown-result-is-settled). ## When a person must approve 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](https://docs.katara.com/products/tools-and-approvals.txt#decide-an-action) covers the reviewer's side. ## Knowledge for your own runtime 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](https://docs.katara.com/products/knowledge.txt#assemble-context-for-your-own-runtime).