Tools
There are 45 tools, plus 5 optional draft tools. Every ID is a UUID that the
caller chooses. The one exception is a draft: propose_review makes the ID
when the caller sends none. Every write takes an actor. Times are ISO 8601
and must carry a UTC offset (Z, +00:00, +07:00); one without an offset
is refused. Dates are YYYY-MM-DD. Arrays and objects must be sent as
arrays and objects, not as strings. A text argument is kept exactly as sent,
even when the text is itself valid JSON.
An argument that takes one of a fixed list of words, such as work_kind,
tag, status, outcome, confidence or a party's kind, lists its words
in the tool schema. A word outside the list is refused before the database is
asked.
Every tool and every parameter carries a description in the tool schema your harness shows the agent: what the value is, its format and the rule that applies. This page gives the same facts by topic.
Every answer is one JSON object. A tool that lists things puts the list under
a named key, such as {"work": [...]}, and an empty list means there is
nothing to list. A read by an ID that does not exist is refused with
... not found: <id>. That holds for a list read too: the memos of a work
item, the judgments of a milestone, the documents of a job and the
assessments of a document version each refuse a parent that does not exist.
So an empty answer is never a broken one. See Refusals for how
errors read.
Start here
read_case_brief(id): the whole chain that a quote, booking or job belongs to, as one JSON document{"brief": {...}}. Any ID in the chain gives the same chain;requested_idechoes the ID that was sent andread_atis the time of the read. Each item carries the SOP version it adopts, its phases, every milestone's latest judgment with its citations (or the SOP criterion when it was never judged), the parties in their roles, the evidence linked to it, the latest memo and, for a job, its documents. A milestone that no SOP adoption named showsfrom_sopfalse.chargeslists every milestone with a charge line, with its phase name;read_work_moneygives the amounts.invoiceslists the bills linked to each item; one with no lines reads "billed, stated total, lines not recorded", or "billed, total not stated, lines not recorded" when its total is unknown. A charge an SOP named also stays in its phase (or inhistory_milestones); a charge no SOP named is listed only undercharges. Phases come in the order of the SOP the item follows now (sop_order), then any the agent added. After a job changes SOP, milestones only the earlier SOP had leave the phases and are listed underhistory_milestoneswith their phase name and last status; a milestone the new SOP copied word for word stays in its phase. The last section,not_recorded, lists what is missing, such as a work item that adopts no SOP version or roles that cite no evidence, so a blank is not read as "nothing to say".chainnames the root of the chain. An unknown ID is refused as not found.list_work(kind, limit): every quote, booking and job, newest first, as{"work": [...]}. It is the only read that needs no ID. Use it before creating work, so the same enquiry does not become two quotes. Each entry haskind,id,title,actor,recorded_at, its outside reference numbers,latest_memo_atandsop_version_id.limitdefaults to 200 and may be at most 1000. There is no status and no open flag.
Work: quotes, bookings and jobs
create_quote(id, title, actor): create a quote, the first record of work someone asked for and the root of a chain.create_booking(id, quote_id, title, actor): create a booking under an existing quote: quoted work that is going ahead. A quote may have several.create_job(id, title, actor): create a job, one piece of work that is carried out. A job may stand alone.link_job_booking(job_id, booking_id, actor): put an existing job under an existing booking, which joins it to that quote's chain. A job can have one booking; a different second link is refused. A link is never removed.read_work(id): one quote, booking or job with its chain of relationships and its party roles.
An ID names one kind of work only. Reusing an ID with the same contents is a safe retry.
Evidence and files
record_evidence(id, original_payload, attachments, actor, source_locator, source_at, about_work): record a source unchanged. It needs a text payload or at least one attachment. Each attachment is{sha256, media_type, byte_size}and its bytes must already be in the file store.about_worklists the work this evidence is about, one{id, work_kind, work_id}per work item, whereidis a new UUID for the link; evidence and links are saved together.link_evidence_work(id, evidence_id, work_kind, work_id, actor): say later that an evidence row is about a work item. Links are never removed.read_evidence(id): the full original record, itspayload_sha256and itsabout_worklinks.find_evidence(payload_sha256, attachment_sha256, source_locator, limit): answers "have I recorded this already?". Give exactly one key. Matching is exact, including case. It returnsmatch_count,has_moreand a short list. It never merges rows, and recording the same source twice is allowed. Look up first, and cite the existing ID when there is one.put_file(evidence_id, contents_base64, media_type, actor): store file bytes, up to 8 MiB, and record an evidence row that names them. It returnssha256,byte_sizeandevidence_id. The new row is not linked to any work until you link it withlink_evidence_work. The row is saved first, the bytes second. If the byte write fails, the row exists andread_filereports that hash as not stored; call again with the same ID and the same bytes to complete the store. The same ID with different bytes is refused.record_file_by_hash(evidence_id, sha256, media_type, actor): the same, for bytes that should not pass through the model. Another program leaves the file in the drop directory, named by its hash. The server checks that the bytes match the name before it writes anything. Then the order is the same asput_file: row first, bytes second, and the same call again completes a store that failed part way.read_file(sha256): the bytes, base64-encoded inbytes, checked against the hash. If an evidence row names the hash but the bytes are not on disk, it returns{"sha256": ..., "stored": false}.
Files are limited to 8 MiB. Hashes are 64 lowercase hex characters. No tool takes or returns a path on the server, and no tool deletes a file. Two evidence rows may name the same hash: that is two rows and one stored file. A file stored this way is not linked to any work until you link it.
Links matter for citations. Evidence with no links can be cited from any work. Evidence with links can be cited only by a judgment on one of the linked work items. So link the evidence to this work first, then cite it.
Phases, milestones, reviews and memos
create_work_phase(id, work_kind, work_id, name, display_order, actor): create a phase on one work item.create_work_milestone(id, work_kind, work_id, title, actor, phase_id): create a milestone, optionally inside a phase on the same work item. The phase of a milestone cannot be changed later.record_review(work_kind, work_id, actor, judgments, memo, roles): the way to record judgments. One transaction saves every judgment, the required memo and any party roles claimed in the review, and links them together. Each judgment hasid,milestone_id,status,explanationand at least oneevidence_idsentry. It may carryoccurred_onwithoccurrence_precision(exactorby), one earlier judgment inrevises_idsand others inbased_on_ids. The memo hasid,review_started_at,review_ended_at,summary,decisionandchanged, plus optionalwaiting_for,next_review_at,details,reviewed_milestone_idsandreviewed_judgment_ids. A milestone may be judged once per review. Judgments needchangedto be true. Everything must belong to the named work item. One bad entry rolls back the whole review.leave_work_memo(id, work_kind, work_id, ...): a memo with no judgment, for a look at the work that judged no milestone. It takes the same memo fields.review_started_atandreview_ended_atsay when the look began and finished; the end must not be before the start.changedsays whether it learned anything new.decisionsays what the reviewer chose to do about it; "no action" is a decision.read_work_review(id, include_evidence): a compact snapshot of one work item: its phases, its milestones with their latest judgments, and a pointer to its current SOP adoption. It shows every milestone, including ones no SOP governs.read_work_memos(work_id, limit, before_recorded_at, before_id): memos, newest first, as{"memos": [...]}. Pass both cursor values to page.read_history(milestone_id, limit, before_position, include_evidence): every judgment of one milestone, newest first, as{"judgments": [...]}.limitis at most 200.
The latest judgment of a milestone is the last one appended. occurred_on is
a calendar date that says when the thing happened. recorded_at is always the
time of the write.
Documents on a job
create_document_requirement(id, job_id, title, actor, milestone_id, description): say that a job needs a document, optionally for one of that job's milestones.submit_document(id, requirement_id, sha256, media_type, actor, replaces_submission_id): add a stored file as the next version. The first version replaces nothing. Each later version must name the current one.assess_document(id, submission_id, outcome, reason, actor, evidence_ids): recordacceptedorrejected, with a reason and evidence. A new version starts with no assessment.read_documents(job_id): requirements, every version and each version's latest assessment, as{"requirements": [...]}.read_document_assessments(submission_id, limit, before_position): every assessment of one version, newest first, as{"assessments": [...]}.
SOP versions and adoption
publish_sop_version(id, name, version, tag, definition, actor, based_on_version_id): publish an SOP. A published version never changes, and anameandversionpair can be published only once.tagisstandardorbreak_glass(a recovery procedure).definitionis{"stages": {"quote": {"phases": []}, "booking": {"phases": []}, "job": {"phases": []}}}. All three stages must be present and may be empty. A phase is{key, name, display_order, milestones}. A milestone is{key, title, criterion}. The criterion is text for the agent to read; the ledger never evaluates it.based_on_version_idrecords where a version came from; nothing is inherited.examples/holds three example definitions from different trades; none is a default.list_sop_versions(tag, status): every version, newest first, without the definition, as{"versions": [...]}.statusisactiveorretired.read_sop_version(id): one version in full.set_sop_version_status(id, sop_version_id, status, reason, actor, evidence_ids): retire a version or make it active again. It needs a reason and at least one evidence ID. A retired version cannot be adopted; work already on it stays on it.adopt_sop_version(id, work_kind, work_id, sop_version_id, reason, actor, evidence_ids, phase_bindings, milestone_bindings, expected_previous_adoption_id): record that a work item follows a version. It needs a reason and at least one evidence ID. The bindings give an ID to every phase and milestone of that stage, each exactly once:{phase_key, phase_id}and{phase_key, milestone_key, milestone_id}. Missing phases and milestones are created in the same transaction. An existing ID must keep its owner and meaning.read_work_sop(work_id, include_evidence): the milestones of the current adoption (current_adoption, null when the work follows no SOP), those an earlier adoption had and this one dropped (historical_removed_milestones), and those no adoption ever named (unadopted_milestones).read_sop_adoption(id, include_evidence): the fixed membership of any adoption, old or current, with judgments as they stand now. It is not a view of the past.
How adoption works:
- A chain chooses its SOP once, at the quote. Adopt on the quote.
- Bookings under that quote, and jobs linked to those bookings, follow it on their own. Each gets the phases and milestones of its own stage when it is created or linked, or when the quote adopts if it already exists. Do not adopt on them; it is refused.
- A job with no booking adopts on its own.
- A job that already adopted a different version cannot be linked under an adopted quote.
- Replacing an adoption, and adopting a
break_glassversion, are allowed on jobs only. A replacement must name the current adoption inexpected_previous_adoption_id; a stale value is refused. Adopting again on a quote or booking that already follows an SOP is refused: only a job can replace its SOP version. The older adoption stays readable. Milestones the new one leaves out remain as history and are not marked done. To keep a milestone and its judgments, bind its existing ID and its phase's existing ID; its title, criterion and phase name must be unchanged, and a changed one needs a new ID. A job under a booking replaces the adoption it inherited the same way. - Nothing picks a version. List the active ones and ask a person, unless your instructions name one.
Parties, identifiers and roles
create_party(id, kind, name, actor, is_operator):kindisperson,agentororganization.is_operatormarks the one organization that runs this ledger; a second one is refused. The name is a label only.record_party_identifier(id, party_id, kind, value, actor, evidence_id): anemail,phone,addressoraliasthe party was seen under. One row per value.record_role(id, party_id, role, actor, on_party_id, work_kind, work_id, confidence, evidence_id, started_on, ended_on, memo_id): a party holds a role. Give exactly one target: another party (a standing role, such as a person who works at an organization) or one work item.roleis any word your business uses.confidenceisconfirmed,mentionedorformer. Aformerrole needsended_on, here and inrecord_review. A role with noevidence_idis reported as uncited in the case brief. Roles passed torecord_revieware tied to that review for you.end_role(id, role_id, ended_on, actor, evidence_id, memo_id): append a replacement row markedformer. The earlier row stays as written. A role that is already ended or replaced is refused.read_party(id): the party, its identifiers, the latest row of each role it holds (roles) and of each role held on it (held_by).find_parties(text): the parties that match what the agent sees. An email, phone or alias equal to the text, ignoring case, is an exact match. Only when there is none, every party whose name or one alias contains every word of the text, or (three characters or more) appears inside the text, ignoring case, so "Coastal Refrig" finds "Coastal Refrigeration Inc" and "Harbor Co" finds the alias "Harbor". Nothing fuzzier: a misspelling finds nothing. It returns{"candidates": [...]}, each withid,kind,name,is_operator,match(email,phone,alias,name_wordsoralias_words) andmatched_value. It never picks.
Parties are never merged. The same company under two names is two rows.
Call find_parties before create_party, so it does not happen.
Money: what is owed and what was paid
This is a record for operations, not accounting. It answers "what do I owe, and to whom?", "what am I owed, and who is late?", "is this bill what we agreed?" and "did I make money on this job?", both ways. The ledger records and the reads do the sums; it files no tax and converts no currency. It takes in what happened, even when it looks wrong, and refuses only the few things that would make the reads lie (see "Rules the ledger enforces").
- A charge is one priced thing on one quote, booking or job: a milestone
with charge lines. One party owes it and one party is owed it. It is judged with
record_reviewlike any milestone:doneis accepted,pendingis waiting for detail,blockedis disputed,failedis waived. - A charge line states one amount. Kind
expectedis what was agreed; kindinvoicedis what a bill says. A charge has at most one current line of each kind. The two are shown side by side and never added. - An invoice is one version of a bill: number, dates, terms as written, stated total. A reissued bill is a new version that replaces the old one.
- A payment is money that moved from one party to another. An allocation puts a payment, or part of it, on a charge or on an invoice, whoever the charge or invoice names.
Writes:
record_charges(actor, reason, evidence_id, lines, invoice, invoice_id): one transaction. One bad line saves nothing: no line, no invoice, no link and no new charge.reasonandevidence_idapply to every row. Lines areexpectedunless the call names a bill:invoicefor a new one,invoice_idfor the latest version of one already recorded. Then they areinvoiced, and each that leaves out its parties takes them from the bill: the recipient owes, the issuer is owed. A line naming other parties than its bill is saved with a warning. The first line of a charge namesowes_party_idandowed_party_id; a later line that leaves them out keeps the charge's. Each line hasid,charge_id,charge_type(any words your business uses) and may havequantity,rate,amount,currency,home_amount,home_currencyandreplaces_line_id. A line for a charge that does not exist yet carriesnew_charge(work_kind,work_id,title, optionalphase_id); new charges are created first.linesmay be empty only with a newinvoice, for a bill whose lines are not known yet. The invoice hasid,from_party_id,to_party_idand may havenumber,issued_on,terms,due_on,stated_total,currency,replaces_invoice_idandwork: one{id, work_kind, work_id}per quote, booking or job it covers, none for a bill of no job such as rent. Give the bill's currency even when its total is unknown, so a payment can be put on it. A reissued bill may name other parties than the bill it replaces. It returnsinvoice_id,line_ids,charge_idsandwarnings.record_payment(actor, reason, evidence_id, allocations, payment, payment_id): one transaction.paymentis a new payment:id,from_party_id,to_party_id,amount,currency, and optionalhome_amount,home_currency,paid_on,reference,method(as written) andreplaces_payment_id.payment_idnames one already recorded, to allocate it later. Send exactly one of the two. Each allocation hasid,amount, exactly one ofcharge_idandinvoice_id, and may havereplaces_allocation_id.allocationsmay be empty: the payment then waits as unallocated. It returnspayment_id,allocation_ids, the payment'sunallocatedremainder and itscurrency, andtargets:paid,openandnotefor each charge or invoice the call touched.
How money is written:
- Numbers are text, such as
"1250.00", or whole numbers, and are kept exactly as written. A number with a decimal point sent as a JSON number is refused, because it may already have lost digits. A line's amount, rate and home amount and a bill's stated total may be below zero: a credit is a line like any other, and the reads add signed numbers. A payment and an allocation are zero or more. Leave a number out when it is unknown; it stays unknown and is never read as 0. - A known amount or rate needs a currency, three capital letters such as
USD. A rate is a price per unit; a percentage goes in the reason. Currencies are never added together. home_amountandhome_currencygo together. They say what an amount was in the shop's own currency, from the bank or a stated rate. The ledger converts nothing. There is no setting for the shop's currency.- A new amount is a new line with
replaces_line_id, naming the current line of the same charge and kind. A discount or an absorbed bank fee lowers the line it concerns this way, with the reason. A lump whose detail arrives later is replaced by 0, and each part becomes a new charge. - A bill for a charge already billed replaces, never adds: a reissued bill
is a new invoice with
replaces_invoice_id, and each changed line replaces the line it changes. A later bill may bill a charge again: its line replaces the charge's current invoiced line and names the new bill. - An agreed amount is recorded once, on the stage where it was agreed, and never restated later; the reads total the whole chain.
- A payment goes on whichever charge or bill it settled, in its currency,
whoever paid: the payment records who paid and who received, and the
reads show both. The shop paying a vendor's bill addressed to its
customer is the shop's payment on that bill's charges. A bounce or a
refund is a new payment back, allocated to the same charge; a payment from
the party owed lowers
paid. - A charge with money on it keeps its currency: a line in another currency is refused until those allocations are lowered to 0.
replaces_payment_idcorrects a recording mistake, including the payer or the receiver. The currency changes only once the payment's allocations are lowered to 0.- To move money, take the allocation's
idandpayment_idfromallocationsin a read, then with thatpayment_idreplace it withreplaces_allocation_id, naming the new target. Money on a bill may stay there after its lines are recorded. - An invoice with the same issuer, recipient and number as another invoice outside its own chain is saved, and the answer carries a warning. An unknown number never warns.
Reads:
read_work_money(id): the whole chain the quote, booking or job is in.itemslists each quote, booking and job with itschargesand theinvoiceslinked to it. Each charge haswork,owes,owed,direction,judgment(orunassessed) withlatest_judgment, the currentexpectedandinvoicedlines with theirearlieramounts,paid,open,allocations(id,payment_id,amount,paid_on,paid_by,paid_toeach),invoice_number,issued_on,due_on,paid_on,days_past_due,noteand two marks:changed_since_judged(the amount or currency of a line changed after the latest judgment; home figures do not count) andbefore_procedure_change(the charge was recorded before its job's latest SOP adoption replaced an earlier one). Each invoice has its latest version's fields,versions,work,lines_recorded,same_number_asand, when it has lines,lines_totalanddifference(stated total minus its current lines, across all work; when not 0, anotegives both figures),allocations,paid(money on its lines and on the bill) andopen(its lines minus that). An invoice with no lines hasnote"billed, stated total, lines not recorded" (or "billed, total not stated, lines not recorded"), with its ownpaid,open,days_past_dueandallocations. Each item hascash: per currency,paid_outandtaken_in, the money the operator paid and received on that item's charges and bills, read from the payments whoever the charges name. It is cash, not margin.totalsgives, for expected and for invoiced,sales,costsandsales_minus_costsper currency;unknown_amount_lines;bills_without_lines, one{direction, currency, stated_total}per bill with no lines linked to the chain, never added to sales or costs; andhome: the same sums in each home currency recorded, awarningwhen more than one appears, andunconverted_lines.homeis null when no line carries a home figure. Waived charges are left out oftotals. An unknown ID is refused as not found.read_money_open(party_id): what is open across the business, or for one party.receivable,payable,between_othersandunknownlist the charges that have an invoiced line and the invoices with no lines whoseopenis not 0, oldest due first, with theirallocations. A charge judgedfailedis listed only while money paid on it makes itsopennot 0.totalssumsopenper direction and currency, with a count of unknown ones inunknown_open;openis null when none is known. Alsounallocated_payments,paid_not_billed(charges with money on them and nothing invoiced),bills_without_lines(invoices linked to work that have no lines yet) andawaiting_judgment(billed charges that are unassessed, or changed since judged). An unknown party is refused as not found.
How the reads count:
- Only the latest row of each chain counts: the line, the invoice version, the payment and the allocation that nothing replaces. "A payment" is its whole chain; allocations follow a corrected payment. The work an invoice covers is the work linked to any version of it, so a reissued bill needs no new links.
paidon a charge is the sum of its allocations, whoever paid: a payment from the party owed takes away, any other adds.openis the current invoiced amount minuspaid; a minus is an overpayment. With nothing invoiced,openis null, and a charge with money on it reads "paid, not yet billed". On an invoice with no lines,openis the stated total minuspaid.- Money put on a bill that has lines stays on the bill; the ledger never
guesses which line it settled. The bill's
openis its lines not waived, minus the money on all its lines (waived ones too), minus the money on the bill (money_on_bill). A charge'spaidandopencount only money put on that charge;bill_holdson the charge shows what its bill holds as a whole.read_money_openlists that money as one row (kindmoney_on_bill, "N paid on bill X, not assigned to lines", or "N refunded on bill X" for money sent back) beside the bill's open lines, and its totals subtract it once. A bill whose open is 0 is settled: neither its lines nor that row are listed. - A bill whose lines all moved to a later bill (a charge billed again) reads "lines now on" and the later bill's number, open null, and leaves the open lists.
- A bill whose current lines name other parties than the bill, as after a
reissue to another party, is marked
parties_differ_from_bill, and so are those charges.read_money_openfor a party lists a charge that names the party or whose bill is from or to it, in the charge's own direction. - A waived charge (judged
failed) owes 0: itsopenis minus what was paid on it, noted "waived, N paid", and listed while not 0. - A charge whose bill holds money as a whole and whose bill reads open
0 or less keeps its
openbut has nodays_past_due: "covered by money on bill X". - A bill some of whose lines moved to a later bill notes "line moved to" that bill before its stated total and lines.
- Each allocation row carries the payment's
referenceandmethod. cashcounts money on a bill as a whole once: on the item of the bill's first line, or for a bill without lines on the first item it is linked to. The bill, listed on any other item, names that item incash_counted_on. Unallocated payments are in no item'scash.totals.others_billsgives, per currency, what the operator paid (paid_on_others_bills) and received (received_on_others_bills) on charges and bills between other parties, with anote: sales minus costs leaves that money out;cashhas it.- Direction is worked out when read, never stored:
payablewhen the operator owes,receivablewhen the operator is owed,between_othersotherwise,unknownwith no operator party. days_past_duecounts fromdue_onto the day of the read, when something is open; it is 0 before the due date. Every read carriesread_at, the time it was read.- Home totals use only what was recorded: a line's
home_amount, or itsamountwhen its own currency is a home currency seen in the same read. - Amounts come back as exact decimal text, such as
"1250.00". - The paid milestone of an SOP is judged like any milestone, citing the payment's evidence. Nothing compares the two.
Outside reference numbers
record_external_reference(id, work_kind, work_id, reference_type, source, value, actor): record a number another system uses for this work.resolve_external_reference(value, reference_type, source, limit): find the work recorded under a number. Matching ignores case and everything that is not a letter or digit, soREF-10422andref 10422match. Nothing else is guessed. It returns every candidate, the value as recorded andmatch_count.has_moremeans the list was cut; narrow by type or source. More than one candidate is reported, never resolved for you.
Optional draft tools
These five exist only when LEDGER_PROPOSALS is set. A draft is a JSON file in
that directory, never a ledger row. Use drafts when a person should confirm a
decision before it is recorded. The person answers in the harness
conversation; the agent then records the outcome with the ordinary tools.
propose_review(proposal, actor, id): write one draft per decision: what would be recorded, the question for the person, the sources, and whether the agent needs the person's judgment (needs_human) or is confident.idis optional; when sent it must be a UUID. Theactormust not be empty. Awork_idmust name an existing quote, booking or job of thework_kindgiven, and every ID insidewould_recordmust be a UUID.message_idsandsourcesare the names the messages have at their source, such as thesource_locatorevidence is recorded under. They are not evidence UUIDs and are not checked.list_proposals(status, work_id): all drafts, oldest first, as{"proposals": [...]}. Read it before proposing, so a decision is not asked twice.read_proposal(id): one draft and what became of it. An unknown ID is refused as not found.mark_proposal_recorded(id, memo_id): after recording, name the memo that carries the outcome. The memo is read back from the ledger first.withdraw_proposal(id, reason, actor): withdraw a draft that was declined or no longer applies. It needs a reason and a non-emptyactor.