Make it an agent
By the end of this lesson, you can process the document inbox through an agent and inspect each run and its token usage.
Version the prompt and record runs
Section titled “Version the prompt and record runs”Two things are still missing, and you need both before anybody else depends on this.
The prompt is a constant in a package body. A better prompt means a compile and a deployment, and you cannot tell afterwards which prompt read which invoice.
Nothing keeps a record of a run. inv_extractions has the token counts because
you wrote that code. Without it, no record of a run survives.
One move solves both: put the prompt on a prompt profile, and put an agent in front of it.
Step 1: the prompt becomes a row
Section titled “Step 1: the prompt becomes a row”A prompt profile holds the system prompt, the response schema, the provider and the model, as a row you can version.
l_profile_id := uc_ai_prompt_profiles_api.create_prompt_profile( p_code => 'INV_EXTRACT', p_description => c_description, p_system_prompt_template => c_system_prompt, p_user_prompt_template => 'Extract this document.', p_provider => uc_ai.c_provider_openai, p_model => uc_ai_openai.c_model_gpt_5_6_terra, p_response_schema => c_schema);
uc_ai_prompt_profiles_api.change_status( p_id => l_profile_id, p_status => uc_ai_prompt_profiles_api.c_status_active);A new version of your extraction prompt is now an insert and a status change, not a deployment.
Step 2: the agent that records every run
Section titled “Step 2: the agent that records every run”An agent of type profile is a name in front of a prompt profile. It does not make
the model any smarter. What it adds is a record:
- one row in
uc_ai_agent_executionsfor every run, - the messages of every run in
uc_ai_agent_messages, - the tokens of every run counted and stored for you.
l_agent_id := uc_ai_agents_api.create_agent( p_code => 'INV_EXTRACT_AGENT', p_description => 'Reads one supplier document and returns its data.', p_agent_type => uc_ai_agents_api.c_type_profile, p_prompt_profile_code => 'INV_EXTRACT', p_timeout_seconds => 120);
uc_ai_agents_api.change_status( p_code => 'INV_EXTRACT_AGENT', p_version => 1, p_status => uc_ai_agents_api.c_status_active);
commit;Three things about that block:
- The same draft trap as the profile.
create_agentmakes a draft too. - The
commitis not optional. UC AI writes the execution row in a transaction of its own. An uncommitted agent row makes that write wait for a lock it never gets, so the run hangs. - The profile is named by code, and nothing checks that it exists. A typo in
p_prompt_profile_codestill creates and activates the agent, and the first run fails.
Step 3: run it
Section titled “Step 3: run it”The agent takes its files as a t_files collection. Every entry needs the same
three values you gave create_file_content in lesson 1: a media type, the BLOB,
and a filename.
l_files := uc_ai_message_api.t_files();l_files.extend;l_files(1).media_type := 'application/pdf';l_files(1).data_blob := l_blob;l_files(1).filename := 'halvorsen.pdf';
l_result := uc_ai_agents_api.execute_agent( p_agent_code => 'INV_EXTRACT_AGENT', p_files => l_files, p_session_id => l_session);The same schema and the same prompt, this time on halvorsen.pdf. One line of
your own code changes, and it is the line that reads the answer:
get_object -> an objectget_clob len -> -1The profile carries the response schema, so
execute_agent parses the answer for you. final_message is a JSON object, and
get_clob returns no value:
-- lesson 2, with generate_textl_invoice := json_object_t(l_result.get_clob('final_message'));
-- here, with execute_agentl_invoice := l_result.get_object('final_message');The three shapes:
| You call | final_message is | How to read it |
|---|---|---|
uc_ai.generate_text with a schema | text holding JSON | json_object_t(get_clob(...)) |
uc_ai_prompt_profiles_api.execute_profile | text holding JSON | json_object_t(get_clob(...)) |
uc_ai_agents_api.execute_agent, profile agent with a schema | a JSON object | get_object(...) |
The result also carries three values the plain call never had:
execution_id: 5429session_id: 59DD7E8FF910A37BE0630201590A5514status: completedStore execution_id on your own row and you can always answer “which run produced
this invoice?”. inv_extractions has a column for it.
Step 4: the whole inbox
Section titled “Step 4: the whole inbox”run_inbox now calls extract_with_agent, and it is short, because lessons 2, 3
and 4 did the work:
<<document_loop>>for doc in ( select id, filename from inv_documents where status = 'NEW' order by id )loop begin l_extraction_id := extract_with_agent(doc.id); l_extraction_id := post_extraction(l_extraction_id); commit; l_handled := l_handled + 1; exception when others then ... end;end loop document_loop;The commit is inside the loop on purpose. One document is one unit of work, and a bad PDF at the end of a run of a hundred must not undo the ninety-nine before it.
The handler is longer than it looks like it needs to be. extract_with_agent
records its own failures. A failure while writing the rows does not. A short
handler then leaves that document at EXTRACTED. That status is not NEW, so the
next run skips it, and not FAILED, so no list of problems shows it. The document
is then in no list at all. So the handler sets the status and writes the message
itself, and only then commits.
Documents handled: 4.What the runs cost
Section titled “What the runs cost”select d.filename, x.seconds, x.prompt_tokens, x.completion_tokens, x.total_tokens from inv_extractions x join inv_documents d on d.id = x.document_id order by x.id;FILENAME SECONDS PROMPT_TOKENS COMPLETION_TOKENS TOTAL_TOKENSferrotek.pdf 3.3 2711 360 3071nordwind.pdf 3.8 1040 403 1443halvorsen.pdf 4.7 4055 475 4530ferrotek-delivery-note.pdf 3.3 2763 297 3060DOCUMENTS TOTAL_TOKENS SECONDS 4 12104 15.1Your prompt_tokens will be very close to this column, because the document and
the prompt are the same every time. Your completion_tokens will not: the model
writes the answer again on every run, and a description that comes back one word
longer changes the count.
Two things in that table:
- A PDF does not cost by its size on disk.
ferrotek.pdfandnordwind.pdfare within 100 bytes of each other, both one page, and one costs 2,711 prompt tokens while the other costs 1,040. The prompt-token count for one document repeats across runs, so this is not noise. It is also not the same between models:gpt-5.4-nanocharges 1,209 prompt tokens forhalvorsen.pdfwheregpt-5.6-terracharges 4,055, for the same file. How a provider counts a document is not published, so store the number for every run. - A second page costs about half again, not double.
halvorsen.pdfis 4,055 prompt tokens against ferrotek’s 2,711. It is also the longer document: eight line items to six, and a block of payment and returns text.
A scanned invoice works too, because the provider renders every page as an image anyway. The same FerroTek invoice as an image-only PDF with no text layer at all is 48,694 bytes instead of 3,838, and it costs 3,494 prompt tokens instead of 2,711. It returned the same net amount, the same total and the same six lines.
Model tiers and reasoning
Section titled “Model tiers and reasoning”06_model_matrix.sql holds the whole experiment: the schema and the system prompt
stay fixed, and only the model and the reasoning level change. It scores every
run in PL/SQL against amounts typed in by hand from the paper, so nothing marks its
own work.
Part 1, one run of each model over the four documents:
=== 1. Model tiers, one run per document ===gpt-5.6-terra 4 of 4 12088 tokensgpt-5.6-sol 4 of 4 12236 tokensgpt-5.6-luna 4 of 4 12667 tokensgpt-5.4-nano 4 of 4 5703 tokensYour wording will differ. What must match is the data, and the checks below.
“4 of 4” means the document type, all three amounts, the line count and the sum of the lines were right on every document.
Every tier got everything right, and the cheapest one did it at less than half the tokens.
Do not take that as settled. An earlier run of the same script scored
gpt-5.4-nano at 3 of 4: on ferrotek.pdf it returned 2091.50 as the net
amount, the subtotal printed on the page, instead of 2136.50 which includes the
delivery charge. So four documents and one pass is an indication, not a
measurement. Run it a few times.
The cheap model on a scanned invoice
Section titled “The cheap model on a scanned invoice”Part 3 runs the same FerroTek invoice three times for each tier, rasterized to an image-only PDF with no text layer at all — a scan:
=== 3. A scan, three runs per tier === nano run 1 net=2091.5 terra run 1 OK nano run 2 net=2091.5 terra run 2 OK nano run 3 net=2091.5 terra run 3 OKThree failures out of three, and the larger model right three times out of three.
The small model returned net=2091.5, the same mistake it made intermittently
on the text version. It did not misread a digit. It stopped applying a rule from
the system prompt, and it did so on every run of the scan. One pass does not show
that.
Reasoning on the cheapest model
Section titled “Reasoning on the cheapest model”Part 2 turns reasoning on, on the cheapest model:
=== 2. Reasoning, on the cheapest model ===gpt-5.4-nano, reasoning low 4 of 4 6186 tokensgpt-5.4-nano, reasoning high 4 of 4 8086 tokensReasoning scored 4 of 4 at both levels, in both runs of the script. It never made an answer worse. But the model without reasoning also scored 4 of 4 in this run, so these four documents cannot show that reasoning fixed anything. What they do show is the price: 8% more tokens at low, and 42% more at high.
The provider guidance says the same. Reasoning effort gives a model room to combine several parts of a page. It does not change how it reads a character. The rule is about the shape of the question:
- Reading a value that is printed — leave reasoning off.
- Combining several parts of the page into one answer — turn reasoning on. A document with totals on page 2 and adjustments on page 1 is the case to try it on.
Which model to choose
Section titled “Which model to choose”- Start with the cheapest tier in the family, and prove it on ten of your own documents, more than once each. That is an afternoon of work.
- Move up a tier for scans and photos, for hand-filled forms and checkboxes, and for scripts other than Latin. Published benchmarks put the largest small-model gaps in exactly those places, and much smaller gaps on multi-column layouts.
- Try reasoning before a bigger model when the failure is a rule not applied rather than a value misread. It is usually the cheaper of the two, and this script is how you find out whether it helps on your documents.
- Keep the checks either way. They tell you which of these cases you have.
Read the execution trace
Section titled “Read the execution trace”UC AI records every run: what the agent was sent, what came back, and what it used.
That record is the trace. None of the queries below read a table you created,
and UC AI writes all of it whether or not inv_extractions exists.
Every run of the agent:
select e.id as execution_id , e.status , e.total_input_tokens as in_tokens , e.total_output_tokens as out_tokens , round(extract(second from (e.completed_at - e.started_at)) + extract(minute from (e.completed_at - e.started_at)) * 60, 1) as seconds from uc_ai_agent_executions e where e.agent_id = ( select a.id from uc_ai_agents a where a.code = 'INV_EXTRACT_AGENT' ) order by e.started_at desc fetch first 6 rows only;EXECUTION_ID STATUS IN_TOKENS OUT_TOKENS SECONDS 5433 completed 2763 297 3.6 5432 completed 4055 475 4.1 5431 completed 1040 468 4.3 5430 completed 2711 360 6 5429 completed 4055 475 4.1Five runs: the four documents of the inbox, and the single run from step 3. Runs
5429 and 5432 are both halvorsen.pdf, so their input tokens match exactly.
What one run did, without needing to know its session id:
select m.seq , m.role , m.tool_name , length(coalesce(m.tool_input, m.tool_output, m.content)) as chars from uc_ai_agent_messages m where m.session_id = ( select e.session_id from uc_ai_agent_executions e where e.agent_id = ( select a.id from uc_ai_agents a where a.code = 'INV_EXTRACT_AGENT' ) order by e.started_at desc fetch first 1 row only ) order by m.seq;SEQ ROLE TOOL_NAME CHARS 1 user 22 2 assistant 956Two rows:
seq 1, 22 characters, is your user prompt:Extract this document.seq 2, 956 characters, is the extraction JSON.
A third row with the role reasoning turns up on some runs and not others,
depending on what the model did. That is normal.
There is no tool_call row, because this agent has no tools. This table tells you
whether a run did what you expected.
From an invoice back to the run that produced it:
select i.invoice_no , x.execution_id , e.status , e.total_input_tokens + e.total_output_tokens as tokens from inv_invoices i join inv_extractions x on x.id = i.extraction_id join uc_ai_agent_executions e on e.id = x.execution_id order by i.invoice_no;INVOICE_NO EXECUTION_ID STATUS TOKENSFT-2026-04417 5430 completed 3071HB-88214 5432 completed 4530NW-2026-1188 5431 completed 1508That join is why inv_extractions stores execution_id. It answers where the
values on an invoice row came from.
Verification
Section titled “Verification”Three checks on the finished inbox.
-
Every invoice adds up. This must return no rows:
select i.invoice_nofrom inv_invoices ijoin inv_invoice_lines l on l.invoice_id = i.idgroup by i.invoice_no, i.net_amount, i.tax_amount, i.total_amounthaving abs(sum(l.line_total) - i.net_amount) > 0.005or abs(i.net_amount + i.tax_amount - i.total_amount) > 0.005; -
Nothing was written that is not an invoice:
select count(*) as should_be_three from inv_invoices; -
Every document reached a final status:
select status, count(*) from inv_documents group by status;One
POSTEDand threeREVIEW. NoNEW, and noFAILED.
Key takeaways
Section titled “Key takeaways”- Put the prompt and the schema on a prompt profile, then put an agent in front of it. A new prompt is then a row, and every run leaves a record you did not write.
execute_agenton a profile with a schema gives youfinal_messageas a JSON object.generate_textandexecute_profilegive you text. Read the one you called.l_invoice := l_result.get_object('final_message');
Full reference: Prompt profiles covers versions and status, and Agentic AI covers agents.
Where to go next
Section titled “Where to go next”You have a pipeline. A document arrives, an agent reads it, your database decides, a person sees only what needs a person, and every run is on the record.
Three directions from here:
- Put it behind a page. A run takes about four seconds, and a page submit cannot wait that long. The APEX Chat plug-in holds the queue, the background job and the poll, and Put an Agent in APEX is a course on it.
- Give the agent tools. This agent has none, which is why its trace has no
tool_callrows. An agent with a tool can look the supplier up while it reads. - Cap what it can spend. A pipeline that runs on every incoming mail needs a budget before it needs anything else.