What you'll learn
- the two requests the SDK makes to your backend;
- every field in a record, when it appears, and what the backend accepts in it;
- what is never sent;
- what the optional desktop signal carries.
The requests
The SDK makes two kinds of request to your backend, both with your API key. It sends records to one address:
POST <your backend URL>/v1/logs/batch
Authorization: Bearer <your API key>
Content-Type: application/json
The body is a JSON list of records, up to ten in one request. Records are grouped by key and backend address, and each goes only with the key it was made with. The request waits at most ten seconds for an answer.
It also reads your workspace's enforcement setting, every five minutes, in the background:
GET <your backend URL>/v1/policies
Authorization: Bearer <your API key>
That request carries no record. Set FOXY_ to stop it, and FOXY_ to change how often it runs. See Modes.
Give the base address as your backend URL, such as https://. Sending records also works with the full ingest address, because the SDK strips the ending, but the read of your workspace's setting does not. It then fails, and only the debug log says so.
The record
Always present
| Field | Type | What it is |
|---|---|---|
event_ |
an id | made on your machine, one for each call |
client_ |
32 hex characters | this installation's lasting name, made up once for each key and address and kept in the spool, unless you set FOXY_ |
client_ |
a whole number | a counter for that name, added when the record is saved in the spool |
event_ |
text | interaction, stream, blocked, redacted, exception or response_ |
commitment_ |
text | how the hashes were made: hmac-, hmac- or sha256- |
prompt_ |
64 hex characters | the commitment of the prompt |
response_ |
64 hex characters | the commitment of the answer |
token_ |
a whole number | a rough count of tokens, not a bill |
policy_ |
text | the policy tag, folded to lower case |
pii_ |
a list of labels | the kinds of personal data found, sorted, with no repeats. On a call checked before it ran, in block or redact mode, it can also hold prompt_ or secret_. |
The counter lets the backend notice a gap: if record 41 arrives and then record 43, record 42 is missing or late. The backend then adds a client_ key to the record it stores.
Present when needed
| Field | When it appears |
|---|---|
agent |
you passed agent= to the decorator |
event_ |
at least one of the keys below applies |
The SDK never sends occurred_, a field the backend also accepts. It sends no time of its own.
Inside event_metadata
| Key | Set by | When |
|---|---|---|
decision |
the checks | a prompt or answer check made a decision |
policy_ |
the checks | rules fired, on the prompt or the answer. Answer rules start with response_. |
blocked_ |
the checks | there is a main reason for the decision |
ruleset_, ruleset_ |
the ruleset | policy_ is not empty |
policy_ |
tag folding | your spelling of the tag was changed by folding |
system_ |
your settings | you set a system, and it is valid |
| your own keys | you, with metadata= |
see the warning below |
A call in observe mode that nothing flagged, from a function that returns plain text and takes none of the keyword arguments listed below, has no event_ at all. That keeps its record the same, field for field, as older versions of the SDK made it. An answer object that carries a model name, an id or token counts gives the record event_, even when nothing was flagged.
When there is no system, system_ is left out, not set to null. The backend checks whether the key is there, and a null would make it refuse the whole request.
Only some metadata keys are sent. The backend accepts keys from a fixed list, and it refuses a whole request that has any other key in it. So the SDK drops a key of yours that is not on the list, with a warning, rather than send it. The metadata the decorator collects by itself is all on that list.
The list is: request_, trace_, session_, provider, model, id, usage, choice_, tool_, retrieval_, decision, blocked_, policy_, ruleset_, ruleset_, policy_ and system_, and client_, which the backend adds itself. Each value, or each item of a list, may be up to 256 characters when written out, and a list may hold up to 64 items. A value over those limits is dropped too, with a warning.
What the backend accepts
The backend checks each field. The values the SDK makes itself always fit. The values you choose are checked before they are sent: agent=, client_ or FOXY_, your own metadata, and a tag or event type you pass to log_.
| Field | What the backend accepts |
|---|---|
policy_, event_ |
lower-case letters, digits and _, up to 32 |
agent |
letters, digits, _ . - : / ( ) + and spaces, up to 128 |
client_, from FOXY_ |
letters, digits and _ . : -, up to 128 |
event_ |
the keys and limits above |
If one value does not fit, the backend refuses the whole request it is in, and every record in it. So the SDK checks them first. agent=, client_ and a tag or event type given to log_ raise ValueError. FOXY_ is logged as an error and not used. A metadata key or value that does not fit is dropped, with a warning. A token count over 10,000,000 is sent as 10,000,000. A tag you give the decorator is checked for you: see Policies and rules.
What the decorator collects by itself
From your function's keyword arguments, when they are there: request_, trace_, session_, provider, model, tool_ and retrieval_. Text is cut to 256 characters, list items to 128, and a list to its first 64 items.
From the answer object, when it has them: id, model, provider, token counts under usage, choice_, and the names of any tools the model asked to call.
The SDK copies these values as your code passes them, and they are sent and linked into your chain for good. So never put personal or patient details in them, such as a patient's name in a session_ or a record number in retrieval_. Use ids that mean nothing outside your own systems.
Worked examples
A call in observe mode that nothing flagged. The hashes and ids here are shortened, and yours will differ:
{
"event_id": "<a new id>",
"client_id": "<32 hex characters>",
"client_seq": 42,
"event_type": "interaction",
"commitment_alg": "hmac-sha256",
"prompt_hash": "<64 hex characters>",
"response_hash": "<64 hex characters>",
"token_count": 137,
"policy_tag": "hipaa",
"pii_signals": []
}
A prompt stopped in block mode:
{
"event_id": "<a new id>",
"client_id": "<32 hex characters>",
"client_seq": 43,
"event_type": "blocked",
"commitment_alg": "hmac-sha256",
"prompt_hash": "<64 hex characters>",
"response_hash": "<the hash of an empty answer>",
"token_count": 12,
"policy_tag": "hipaa",
"pii_signals": ["ssn_pattern"],
"agent": "my-model",
"event_metadata": {
"decision": "blocked",
"policy_rules": ["phi.ssn_pattern"],
"blocked_reason": "phi",
"ruleset_version": "2026.09.3",
"ruleset_hash": "<64 hex characters>"
}
}
The function never ran, so the answer's hash is the hash of an empty answer. The numbers in these examples are only there to show the shape.
What is never sent
- Your prompt, your model's answer, or any part of either.
- Where a rule matched, how long the match was, or the matching text.
- Your commitment key. It is the key in the SDK's key file on your machine, or the one you set with
FOXY_. Only when the SDK can neither read nor make that file does it use your API key, which goes with every request. It logs a warning when that happens.COMMITMENT_ KEY - Your salts. They stay in your own salt file.
- What the optional deep personal-data check found. Only the kind of thing it found is sent, such as
presidio:person. - The note the SDK keeps when the backend refused a field. It stays in your spool, because the backend would refuse it too.
The desktop signal
Unless you turn it off, the SDK also sends small messages to the optional desktop app on your own machine, at 127.0.0.1:9999, whether or not the app is running. Each message is under 4,096 bytes of JSON, and a lost one does no harm:
{"event": "evaluating", "policy": "hipaa", "tokens": 137, "ts": 1790000000.0}
The three messages are:
evaluating: a record has been made, after its hashes, and it is about to be saved;hash_: the record was saved in the spool, withok deliveryset toqueued. With no API key, nothing is saved, anddeliveryislocal_;only policy_: a prompt or answer was stopped. It names up to eight rules.breach
None of them is a receipt from the backend. Turn them off with FoxyClient(desktop_.
What comes back
The backend answers each request with receipts, one for each record. The SDK keeps them in the spool. Only log_ hands one to your code: with audit_ on, it returns the backend's receipt for its record. A decorated function returns its own value, as it always did.
The backend, not the SDK, links each record into your chain. The SDK sends no chain hash and no time of its own: the backend works both out when the record arrives. The backend also adds keys of its own to the metadata it stores: policy_ and policy_, your workspace's policy when the record arrived, and client_ when a record before it is missing or late.
To read your records back, export them: the dashboard's Export page, or GET /. That file is what the verifier and foxy explain read.
Foxy Audit