Exasol’s Agent Control Plane

15 min read Original article ↗

Database + Model = Agent Stack

Agents are non-deterministic. The same prompt against the same model can produce subtly different outputs run to run and across model versions. As teams move toward multi-agent fleets, that variance becomes an operational problem: how do you keep a fleet of agents consistent, auditable, and actually under control?

Deploying agents raises questions most teams don’t have good answers for: “Which prompt generated these results?” “What data did the agent interact with?” “What did it cost?” The answers are scattered across systems, if they’re accessible at all.

For true repeatability, agents in production can’t be free-form. They need controls: a versioned prompt the system actually used, a schema the model is required to match, an immutable record of every call, and a way to join all of that back to the row the agent wrote. At fleet scale, those controls have to be uniform across every agent, or the audit story collapses.

The best place to put those controls is where the data already lives.

Agents should be managed in the same place you manage the data they read and write.

The database that holds your output rows can equally hold the prompts that generated them, the schemas that constrained them, and the audit trail that connects it all.

In this post, we build an Agent Control Plane entirely on Exasol, using a multi-agent document parsing pipeline as proof. A complete agent fleet on infrastructure you already operate. A database and a model is the entire stack.

Agent Control Plane components

With Exasol’s Agent Control Plane, you develop, execute, and monitor agents in one place: your Exasol database. The key components:

Components of the Exasol agent control plane, from the external trigger to the LLM endpoint.
  • agent_registry: Catalog of named agents with owners, status, and descriptions. Everything else (prompts, schemas, runs, calls) hangs off the agent.
  • agent_prompts: Every prompt that ever ran in production is retrievable, by agent and by version, append-only. A new version is a new INSERT; history is never overwritten.
  • agent_output_schemas: One JSON Schema per agent, stored as a row. Passed to the model as a tool definition at call time; the model can’t return a shape that doesn’t match.
  • agent_call_log: One row per LLM call, joinable to the prompt version, the schema, the run, and the data the agent produced. Cost and confidence are first-class columns.
  • agent_models: Model catalog with per-token pricing. Joined at log time to compute cost_usd, making FinOps a SQL query rather than a billing export.
  • Access control: Standard Exasol GRANT/REVOKE governs who can read prompts, author new versions, promote them to active, and inspect call logs.
  • Model credentials: AWS credentials for Bedrock sit in an Exasol CONNECTION object, not in code or environment variables. The same IAM policies your security team already wrote govern model access.
  • Orchestrator UDFs: Exasol User Defined Functions that fetch the active prompt and schema, call the model, validate the response, and write the audit row. A top-level orchestrator UDF chains agents into a multi-step run under a single trace ID.

The Use Case – Document Intelligence

A manufacturer receives supplier invoices as PDFs and needs to reconcile the costs against purchase orders in their ERP. Agents handle this kind of work well, provided their output is constrained and every call is auditable.

  1. Extract. Read the PDF, return structured line items. This works well for an agent as long as the output is locked to a consistent, relational schema with enforced guardrails.
  2. Match. For each invoice line, find the corresponding PO line. Descriptions won’t match verbatim, so this requires semantic reasoning that a SQL join can’t do on its own.
  3. Flag. Compare invoice price and quantity against the PO, mark the variance. Once the data is clean and matched, this is a straightforward SQL expression.

Creating the Agents

Two agents handle this workflow: invoice_extractor and po_matcher. Both go into the agent registry:

INSERT INTO agent_registry (agent_name, description, owner, status) VALUES 
 ('invoice_extractor', 'Reads supplier invoice PDF, returns structured line items.', 
 '[email protected]', 'active'), 
 ('po_matcher', 'Matches invoice lines to PO lines via semantic match.', 
 '[email protected]', 'active'); 

Each one gets its own prompt and schema, scoped to its agent_id. The pattern is uniform: every agent in the registry has at least one active prompt row and one active schema row at any moment.

Creating the Prompts

Each agent gets its own prompt row. Author, model, and version are columns, not metadata buried somewhere else.

INSERT INTO agent_prompts (agent_id, prompt_text, model_id, version, status, created_by) 
SELECT a.agent_id, 
'You extract line items from supplier invoices via the submit_invoice_extraction tool. 
Transcribe only what is printed. Preserve text verbatim. Never guess...', 
'anthropic.claude-sonnet-4-6-20250514-v1:0', 
'v1.0', 'active', '[email protected]' 
FROM agent_registry a 
WHERE a.agent_name = 'invoice_extractor'; 
INSERT INTO agent_prompts (agent_id, prompt_text, model_id, version, status, created_by) 
SELECT a.agent_id, 
'You match invoice line items to purchase order lines via the submit_po_match tool. 
Descriptions will not match verbatim — use semantic similarity to find the best match. 
If no confident match exists, set matched_po_line_id to null and explain in warnings...', 
'anthropic.claude-sonnet-4-6-20250514-v1:0', 
'v1.0', 'active', '[email protected]' 
FROM agent_registry a 
WHERE a.agent_name = 'po_matcher';

These examples are intentionally minimal. Production prompts will be longer, with more constraints and edge-case handling. Give the model enough instruction to produce the right output shape; adding more doesn’t always help.

Most major model APIs support constrained structured output: Bedrock’s Converse API, the Anthropic SDK, OpenAI (response_format with json_schema), and Gemini (response_schema) all offer variations of the same mechanism. The examples here use Bedrock, but the pattern is the same everywhere: pass a JSON Schema as a tool definition and force the model to use it. The model is required to invoke the tool, and the tool’s input must match the schema. Malformed responses get rejected before they reach your application code; the output is schema-valid by construction.

resp = bedrock.converse( 
modelId = model_id, 
system = [{'text': prompt_text}], 
toolConfig = { 
'tools': [{ 
'toolSpec': { 
'name': schema_name, 
'description': 'Submit the structured invoice extraction.', 
'inputSchema': {'json': schema}, # row from agent_output_schemas, deserialized 
}, 
}], 
'toolChoice': {'tool': {'name': schema_name}}, 
}, 
messages = [...], 
) 

Constraining the response format also narrows what the model has to do: it picks values, the protocol handles structure. A smaller, well-defined task is a more repeatable one: less surface area for the model to drift across prompt versions or model upgrades.

The executor UDF, calling Bedrock

The Exasol UDF that runs the extraction agent. Bedrock is the model layer. AWS credentials come from a CONNECTION object in Exasol. PDFs are read from BucketFS, Exasol’s distributed file system, which is accessible as regular files inside UDF scripts.

CREATE OR REPLACE PYTHON3 SCALAR SCRIPT run_extract_agent ( 
run_id DECIMAL(18,0), pdf_path VARCHAR(2000) 
) EMITS (call_id DECIMAL(18,0), line_seq INTEGER, description VARCHAR(512), ...) AS 
import boto3, pyexasol 
def run(ctx): 
cx = pyexasol.connect(...) 
# 1. Fetch the active prompt + schema from the control plane. 
prompt_id, prompt_text, model_id = cx.execute( 
    "SELECT prompt_id, prompt_text, model_id FROM agent_prompts " 
    "WHERE agent_id = ... AND status = 'active' LIMIT 1" 
).fetchone() 
# ... fetch schema row from agent_output_schemas 
 
# 2. AWS credentials come from an Exasol CONNECTION — no keys in code or env vars. 
aws = ctx.exa_get_connection('aws_bedrock') 
bedrock = boto3.client('bedrock-runtime', ..., 
                       aws_access_key_id=aws.user, aws_secret_access_key=aws.password) 
 
# 3. Call Bedrock with the schema pinned as a tool (see "Constraining output" above). 
resp = bedrock.converse(modelId=model_id, ...) 
extraction = next(b['toolUse']['input'] for b in resp['output']['message']['content'] if 'toolUse' in b) 
 
# 4. Log the call to agent_call_log (cost, tokens, prompt version), then emit typed rows. 
cost_usd = (resp['usage']['inputTokens'] / 1e6) * 3.00 + (resp['usage']['outputTokens'] / 1e6) * 15.00 
cx.execute("INSERT INTO agent_call_log (..., cost_usd) VALUES (..., :c)", {'c': cost_usd}) 
call_id = cx.execute("SELECT MAX(call_id) FROM agent_call_log").fetchone()[0]  # use a SEQUENCE in production 
for item in extraction['line_items']: 
    ctx.emit(call_id, item['line_seq'], item['description'], ...) 

/ 

Multi-agent: chaining with runs

Where one agent handles a single step, a chain becomes a pipeline: each agent does one focused job, writes its results to a typed table, and logs its own audit row. The orchestrator holds the sequence together: it opens a parent agent_runs row, calls each agent in order via SQL, and closes the run with a final status. Every child call links back to the parent via run_id, so the full trace of a pipeline execution is a single join on agent_call_log.

Chaining is worth it when steps have meaningfully different prompt or model requirements, or when intermediate results need to be stored for review between steps. For a single-step extraction with no downstream reasoning, one agent and one UDF is simpler and the right default.

CREATE OR REPLACE PYTHON3 SET SCRIPT reconcile_invoice ( 
pdf_path VARCHAR(2000) 
) EMITS (run_id DECIMAL(18,0), status VARCHAR(16), total_cost_usd DECIMAL(10,6)) AS 
def run(ctx): 
cx = pyexasol.connect(...) 
# 1. Open a parent run row — every child call links back here via run_id. 
# ... INSERT INTO agent_runs, capture run_id 
 
# 2. Chain agents in order. Each UDF writes its own call_log row under this run_id. 
cx.execute("INSERT INTO invoice_line_items SELECT * FROM run_extract_agent(:r, :p)", {...}) 
cx.execute("INSERT INTO po_matches        SELECT * FROM run_match_agent(:r, :p)",    {...}) 
 
# 3. Flag variances in pure SQL — no LLM needed once the data is clean. 
# ... INSERT INTO variance_report SELECT ... price/quantity delta expressions 
 
# 4. Roll up cost, close the run, emit the summary row. 
total_cost = cx.execute("SELECT SUM(cost_usd) FROM agent_call_log WHERE run_id = :r", ...).fetchone()[0] 
# ... UPDATE agent_runs SET status = 'complete' 
ctx.emit(run_id, 'complete', total_cost) 

/ 

End-to-end, one invoice through two agents becomes as simple as:

SELECT * FROM reconcile_invoice('/buckets/incoming/inv_2026_03.pdf');
Run_id statusTotal_leakage_usd
1842complete0.063201

One call, two agents, one run row, two call log rows: results land in all relevant tables and columns including invoice_line_items, po_matches, and variance_report. Every step is replayable and every cost is attributed.

Now a PDF like the following is represented and queryable as a relational sql table:

A use case showcasing how a multi-agent setup in a database can extract data from files, in this case, a PDF.
line_seqpart_numberdescriptionquantityunit_priceline_total
1ASC-HX-M10-50Hex Bolt M10×50mm, Grade 8.85000.8500425.00
2ASC-MS-M6-25Machine Screw M6×25mm, A2 Stainless10000.4200420.00
3ASC-WS-FW-M10Flat Washer M10, Zinc Plated5000.080040.00
4ASC-TR-M12-1000Threaded Rod M12×1000mm, Grade 4.61004.7500475.00
5ASC-SA-50x50x5Structural Angle 50×50×5mm, 1m5013.5000675.00
6ASC-PL-200x200x10Steel Plate 200×200×10mm, S3553018.2500547.50
7ASC-NU-M10Hex Nut M10 DIN 934, Zinc Plated5000.150075.00
8ASC-LK-M10Lock Washer M10, Spring Steel5000.110055.00
9ASC-AS-M8-40Anchor Bolt M8×40mm, Hot-Dip Galvanized2001.2000240.00
10ASC-CHN-8mmSteel Chain 8mm Grade 80, per meter758.4000630.00
11ASC-EYL-M16Eye Bolt M16, Forged Steel DIN 580303.6500109.50
12ASC-CL-M12Clevis Pin M12, A2 Stainless Steel602.1000126.00
13ASC-EXP-FREIGHTExpedited Freight Surcharge (3-day air)185.000085.00

Once we’ve extracted the data, this opens up analytics opportunities – we can now actually build holistic and flexible reporting using the extracted data.

Invoice Leakage Dashboard

From here, we can build out robust analytics dashboards with any BI tool to further engage with and analyze data at scale.

An example of an analysis using an agentic database to generate an invoice leakage review dashboard.

Triggering the Pipeline

Another critical component is the ability to schedule or trigger your agent fleet to run – either on a schedule or driven by an external trigger. This can also be coordinated directly from within Exasol as well with the Exasol Scheduler. A lightweight, table-driven scheduler that runs directly against Exasol. Tasks are rows in a SCHED_TASKS table; execution history lands in SCHED_HISTORY, in the same database as your agent control plane. Add, pause, or reschedule with a plain SQL UPDATE; no process restart required. Task definitions, agent state, and run history are all queryable together.

External scheduler. Airflow, Dagster, Prefect, or plain cron works if you already run one. The scheduler fires a JDBC call against the pickup UDF on an interval; the control plane handles idempotency and audit.

Event-driven. An S3 PUT event triggers a Lambda that calls the orchestrator UDF via JDBC. Latency is seconds. Best fit for “process it the moment it arrives.”

Manual. A button in a UI calls the same UDF. Worth keeping even after automated triggers are live; it’s the natural feedback loop while tuning prompts.

Considerations

Prompt segmentation & iteration. One generic prompt gets you most of the way. Where it breaks down: vendors with non-standard invoice formats, document types with different field vocabularies, customers with regulatory requirements that change what you capture. When that happens, add a targeted prompt row for the specific case and let the lookup fall back to the generic. The discipline: every variant is a versioned row in agent_prompts, auditable like any other. No branching code paths, no hardcoded conditionals.

Confidence. Tool-use guarantees the shape of the output, not the correctness of the values inside it. A self-reported confidence field in the output schema is the cheapest signal: the model emits a score, you store it as a column, and anything below a threshold routes to human review. Models are systematically overconfident; treat the score as a triage signal rather than a calibrated probability. For higher-stakes extractions, a “judge” agent that grades the first agent’s output (its own row in agent_registry, its own audit trail) gives an independent signal at the cost of one extra call.

Model routing. Not every step in a pipeline justifies a frontier model. A PDF extractor that needs to transcribe faithfully is a different job from a matcher reasoning across ambiguous vendor descriptions; size the model to the task. Smaller or open-source models handle deterministic, well-constrained extraction fine; a more capable model earns its cost on semantic matching and classification. Since model_id lives on the prompt row, swapping a model is a new INSERT and a promotion, with no code changes and a full audit trail of which model ran on each call.

Inference configuration. Temperature matters. Extraction agents that should transcribe literally want temperature 0; the same invoice should produce the same output every run. Matching and classification agents can tolerate a small non-zero temperature for handling ambiguous cases. Set maxTokens conservatively: a three-page invoice extraction rarely needs more than 2K output tokens, and a tight ceiling is a cost ceiling too.

Cost. Because cost_usd is a column, budget enforcement is a SQL query. A daily ceiling alert is a SELECT on agent_call_log. If spend spikes, the first diagnostic is also a SELECT: agent, model, prompt version, document type. No separate FinOps tool, no billing export required.

Retries and timeouts. Model APIs throttle at low default quotas; wrap calls in exponential backoff for transient errors. A Bedrock call on a multi-page PDF can run 5 to 30 seconds, so invoke the orchestrator UDF from an async worker (Airflow, cron, or an event trigger), not from an interactive session.

Failure handling. When schema validation fails, the UDF logs and stops. The orchestrator marks the run as needs_review in agent_runs. That status column is the work queue; a SQL view on top of it is all you need to build a reviewer UI. Failures are queries, not log greps.

The Audit Payoff

Many questions arise as you manage and grow your agentic fleet. On a typical stack, each questions sends you to a different tool for answers: logs, billing dashboards, version control, etc. On the control plane, each is a SQL query against the same tables you already query for your data.

Which prompt was active when this invoice was processed?

SELECT l.call_id, l.called_at, a.agent_name, p.version, p.prompt_id, l.confidence 
FROM agent_call_log l 
JOIN agent_prompts p ON p.prompt_id = l.prompt_id 
JOIN agent_registry a ON a.agent_id = l.agent_id 
JOIN agent_runs r ON r.run_id = l.run_id 
WHERE r.target_object = '/buckets/incoming/inv_2026_03.pdf'; 
call_idcalled_atagent_nameversionprompt_idconfidence
C-1842-0012026-03-17 09:12:04invoice_extractorv1.0P-0070.940
C-1842-0022026-03-17 09:12:09po_matcherv1.0P-0120.912

Full trace of a chained run, in order:

SELECT a.agent_name, l.called_at, p.version, l.input_tokens, l.output_tokens, 
l.cost_usd, l.confidence, l.schema_valid 
FROM agent_call_log l 
JOIN agent_prompts p ON p.prompt_id = l.prompt_id 
JOIN agent_registry a ON a.agent_id = l.agent_id 
WHERE l.run_id = 1842 
ORDER BY l.called_at; 
agent_namecalled_atversioninput_tokensoutput_tokenscost_usdconfidenceschema_valid
invoice_extractor09:12:04v1.02,8403120.0042600.940true
po_matcher09:12:09v1.04,1208900.0257100.912true
po_matcher09:12:11v1.03,9807440.0223200.888true
po_matcher09:12:13v1.03,7606300.0208800.901true

Daily cost by agent and model:

SELECT TRUNC(l.called_at) AS day, a.agent_name, l.model_id, 
       COUNT(*) AS calls, SUM(l.cost_usd) AS spend_usd 
FROM   agent_call_log l 
JOIN   agent_registry a ON a.agent_id = l.agent_id 
WHERE  l.called_at > CURRENT_DATE - 30 
GROUP  BY TRUNC(l.called_at), a.agent_name, l.model_id 
ORDER  BY day DESC, spend_usd DESC;
dayagent_namemodel_idcallsspend_usd
2026-05-21po_matcheranthropic.claude-sonnet-4-6-20250514-v1:02470.9491
2026-05-21invoice_extractoranthropic.claude-sonnet-4-6-20250514-v1:0190.0809
2026-05-20po_matcheranthropic.claude-sonnet-4-6-20250514-v1:03641.3978
2026-05-20invoice_extractoranthropic.claude-sonnet-4-6-20250514-v1:0280.1193

Versioning, enforced

The audit trail is trustworthy because agent_prompts and agent_output_schemas are append-only. Writing “we never UPDATE” in a comment doesn’t actually stop anyone. Two layers do the enforcing:

  1. Privilege model. Revoke UPDATE and DELETE on agent_prompts and agent_output_schemas from every role except a single agent_admin service account.
REVOKE UPDATE, DELETE ON agent_prompts FROM PUBLIC; 
REVOKE UPDATE, DELETE ON agent_output_schemas FROM PUBLIC; 
GRANT SELECT, INSERT ON agent_prompts TO agent_developer; 
GRANT SELECT, INSERT ON agent_output_schemas TO agent_developer; 

Developers can author new versions, never edit existing ones.

2. Atomic promotion. The act of activating a new prompt version (deprecating the previous active row and activating the new one) happens in a single transaction. This is the only path that uses UPDATE, and only agent_admin can execute it, whether wrapped in a Lua script, a Python helper, or called directly from an admin tool:

-- Two UPDATEs in one transaction. No other code path touches these rows. 
UPDATE agent_prompts SET status = 'deprecated' 
WHERE agent_id = (SELECT agent_id FROM agent_registry WHERE agent_name = 'invoice_extractor') 
AND status = 'active'; 
UPDATE agent_prompts SET status = 'active' 
WHERE agent_id = (SELECT agent_id FROM agent_registry WHERE agent_name = 'invoice_extractor') 
AND version = 'v1.3'; 
COMMIT;

A developer authors a new version by INSERT (as status = ‘draft’). The admin runs the promotion. The historical row is never edited, never deleted.

Try it yourself

You can deploy this model yourself by following along with this SQL workbook; easily customizable and repeatable for your agentic workloads.