Skip to content

The ML services

Four services on this platform can be called on their own, without starting a conversation and without running an agent: document analysis, OCR, speech to text and personal data detection. They are the same implementations the agents use, reached directly — one set of parsers, one set of detectors, one transcription client.

They exist because another component may need them. A queue that has to read a scanned application form, a batch job redacting an export, a service that wants a transcript — none of those want a chat window, and none of them should have to pretend to be one.

What is delivered, and what is not

Every service family is a row here. served means an endpoint on this deployment answers it with the engine named beside it. dependency means the family is required and something is missing, and the note says what. prepared is architecture preparation: no engine ships, and the seam it would arrive through is named.

GET /api/v1/ml/services answers with this same table, so an integration can read it rather than trusting a page.

Service Requirements Endpoint State Engine
document_analysis FA-069, FA-070 POST /api/v1/ml/documents/analyze served LiteParse or PyMuPDF, locally
ocr FA-069, FA-071 POST /api/v1/ml/documents/ocr served LiteParse OCR: bundled Tesseract, or a registered OCR server
speech_to_text FA-069, FA-072 POST /api/v1/ml/audio/transcriptions served The organization's own transcription endpoint
pii_detection FA-069, FA-073 POST /api/v1/ml/privacy/pii served The pattern detectors the guardrails use
pii_named_entities FA-073, DA-007 — dependency None on this deployment
image_analysis FA-074 — prepared None on this deployment

Two rows say no, and both say why. Named entities — a person's name, a postal address — are not pattern-shaped, so no regular expression finds them: that needs a named-entity model per language in scope. The detection endpoint carries the extra categories the day one is provided, and until then it does not claim them. Image analysis is marked as future scope in the requirements themselves.

Calling one

Authentication, the organization header and the error envelope are the HTTP API's own. There is no separate key, no separate host and no second way in, which is deliberate: a surface with its own front door is a surface with its own mistakes.

One permission gates all four: ml:invoke. It is deliberately not agents:run — an integration that parses documents should not thereby be able to spend the organization's model budget. Every role but Viewer holds it.

curl -X POST "$BASE/api/v1/ml/documents/ocr" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Organization-Id: $ORG_ID" \
  -F "file=@scan.pdf" \
  -F "language=deu"

The answer carries the pages in order, the prepared chunks, and the file's size and hash:

{
  "filename": "scan.pdf",
  "filetype": "pdf",
  "byte_size": 184320,
  "content_hash": "9f2c…",
  "pages": [{"page_num": 1, "content": "Antrag auf …"}],
  "chunks": ["Antrag auf …"]
}

Document analysis

POST /api/v1/ml/documents/analyze reads what a document already carries. The parser field chooses between liteparse, which preserves the layout and reads office formats where LibreOffice is installed, and pymupdf, which reads PDFs and is faster. chunk_size, chunk_overlap and chunking_strategy shape the prepared chunks; the strategies are recursive, fixed and markdown, and a fourth spelling is refused rather than quietly treated as recursive.

chunk_overlap has to be smaller than chunk_size. Equal is refused, and not for tidiness: the splitter accepts it and then advances by about one separator per chunk while keeping an almost complete copy of the last one, so a legal upload answers with a document multiplied many times over.

A document with nothing readable in it is refused rather than answered with an empty page list, and the refusal says to call OCR instead — which is what a scan parsed for its text layer always needs.

OCR

POST /api/v1/ml/documents/ocr recognises the text on every page, whether or not the page carries a text layer. That is the difference from ingestion, which auto-detects and skips recognition where the text is already there: a caller who asked for OCR asked for the pages to be read as images.

language is a Tesseract code, which is three letters — pol, not pl. The shipped image installs eng and pol, and those are the two the endpoint accepts: a code with no language pack behind it fails inside the parse, so it is refused at the boundary instead. A deployment that installs more packs widens the list in the same change.

ocr_service_id names an OCR server registered under local services, so a deployment running its own recognition sidecar sends the pages there; omit it and the engine bundled with the parser reads them. Either way the pages stay on the deployment's own network.

A .docx is refused here, although the parser reads one. Ingestion routes office documents to the native reader before it consults the OCR parser, so accepting one would extract its existing paragraphs, skip its scanned pages and say nothing about the difference. Convert it to PDF.

One call recognises at most 200 pages and has 120 seconds, both narrower than ingestion's, because a caller is waiting on this and nobody waits on an ingestion.

Speech to text

POST /api/v1/ml/audio/transcriptions transcribes a recording on a key from the organization's vault. provider and model name what to use; omitting both takes the organization's speech-to-text default from the Voice card, or the deployment's first offered pair where it has none, and naming a provider without a model takes that provider's first model. What it never does is fall back to the default provider when a provider was named.

A recording is also held to the transcription client's own 25 MB ceiling even where ML_MAX_UPLOAD_SIZE_MB is higher, so an over-large recording is refused as being over-large rather than reaching the engine and coming back as a 503 about a key.

The key is named, never looked up. secret_id names the vault key to send the recording with: an API key whose purpose is the provider, which the caller can view. Omit it and the organization's default key is used, but only for the provider that key was chosen for; any other provider without a secret_id is refused as not_configured. A key named for another provider, of another kind, or one the caller cannot see is refused on the secret_id field. The deployment's own OPENAI_API_KEY is never used.

Every level that sets up speech to text works the same way: the model is a pick from the speech-to-text catalog, and the key is a vault secret named beside it. No model profile is read - profiles are what a text run uses - and nothing picks a key because it happens to be the only one for a provider. With two OpenAI keys in the vault, the one named is the one that pays.

No self-hosted speech-to-text server yet

Every catalogued provider is reached at its own address, with a key. A keyless server on your own network - a Whisper behind an OpenAI-compatible API - is not reachable today. It is a planned follow-up: a local service of a new kind, speech, named in place of a key, as collections already name an embedding or an OCR server.

A refusal says why in details.reason, here and on dictation alike. The provider's own error text is logged and never returned, because a provider client puts the request URL, key and all, in its message.

reason Status What happened
not_configured 503 No usable key is named for the provider - none chosen, deleted, or no longer one that level may use; for dictation, also server dictation not set up
empty 400 The engine heard no speech, or the recording has no bytes
unsupported_type 400 Dictation only: the recording is not 16 kHz mono 16-bit PCM WAV - or, where the deployment accepts compressed audio, WebM, Ogg or MP4 - or, on a hosted page or a widget, a compressed recording's length cannot be read. details.accepted lists the types accepted
too_large 400 Over 25 MB, or over the organization's dictation cap in bytes or in measured length; details.limit_seconds names the limit
provider_error 503 The provider refused or failed the request
timeout 503 The provider did not answer within 60 seconds

Dictation in chat

POST /api/v1/agents/{agent_id}/dictation is the microphone in the /chat composer, when the organization transcribes on the server. It takes one recording as file and answers {"text": "..."}, which the composer puts into the box for the person to edit; nothing is sent until they press Send. The caller names no provider, model or language — those resolve from the agent and the organization — and the person may dictate to any agent they may run, a Viewer with a use grant on it included. An agent they may not run answers 404, as everywhere else.

The switch is the agent's. A recording is transcribed only while the agent's published Dictation switch, speech_to_text.dictation, is on; off, the composer uses the browser's own recognition, and a recording posted anyway is refused as not_configured, as it is for an archived agent. The provider, model and key then resolve from the agent and the organization. From the organization, set on the Voice card by somebody holding org:settings, come speech_to_text_provider and speech_to_text_model (a pair from the speech-to-text catalog), speech_to_text_language (an ISO-639-1 code, or null to let the engine detect it) and dictation_max_seconds (10 to 600, 300 by default), which only the organization sets.

GET /api/v1/agents/{agent_id}/dictation, with the same access, says how the microphone will transcribe before anybody records: engine (server or browser), requested (the agent's switch), server_available, unavailable, max_seconds, provider and format. server_available needs a pair that resolves for this agent, the catalog still offering it, and a usable key named beside the pair - checked for existence, never opened; engine is server when that holds, the switch is on and the agent is not archived, and browser otherwise.

Where it is false, unavailable says which half is missing: no_provider (no level names a pair), not_offered (the catalog withdrew it) or no_key (no usable key is named - choose one). The embed form reads server_available, because a hosted page or a widget is not gated by the agent's /chat switch.

provider is the one a recording would reach, and format what to record in for it: wav, unless the deployment accepts compressed audio and the speech-to-text catalog says the provider takes what MediaRecorder makes, in which case webm. Decided here, so no client keeps a list of its own. An agent with nothing published answers from the organization's settings.

By default a dictation is raw PCM: audio/wav (or audio/x-wav, audio/wave) holding 16 kHz, mono, 16-bit integer PCM, which every browser records from Web Audio. The file is read strictly - one fmt chunk saying exactly that, one data chunk of whole samples, sizes that agree with the file - and any other WAV, a stereo, 48 kHz, 24-bit or float one included, is refused as unsupported_type. The provider is sent the counted samples behind a header the server builds, never the caller's header or any other chunk the file held, as recording.wav.

Where the deployment accepts compressed audio, audio/webm, audio/ogg and audio/mp4 are accepted too, a codec suffix allowed, with audio/x-m4a and audio/m4a read as audio/mp4, and audio/opus and audio/x-ogg as audio/ogg. They are what browsers' MediaRecorder produces, and the provider receives them as recording.webm, recording.ogg or recording.m4a, from the same table that names a channel voice note voice.<ext>.

The route reads at most 25 MB of the body; the organization's byte cap - the bytes dictation_max_seconds plus three seconds of PCM take, 32,000 a second, and 4 KB for the container - is applied to what was read. A body the deployment's request limit refuses before the route runs answers 413 without a reason, which the composer reads as too_large.

Then the length is measured before the provider is called, and a recording longer than dictation_max_seconds is refused as too_large, with three seconds' grace because the composer stops on a once-a-second tick. For PCM the length is the samples counted, exactly. A compressed recording's is read from its own header - the last Ogg page, the WebM block timestamps, the MP4 movie or fragment headers - which a file built by hand can understate. One whose length cannot be read is accepted here, under the byte cap; a hosted page or a widget refuses one.

Each person may dictate RATE_LIMIT_DICTATION_PER_MINUTE times a minute, 10 by default, on a counter of its own, so dictating does not spend an integration's ML quota. Every dictation leaves one call record, under speech_to_text and counted in seconds of audio - for PCM, the length counted. The composer sends the length it recorded as a duration form field beside file, used only for a compressed recording nothing else states a length for.

The audio is never stored: it is discarded when the call returns, and the text exists only in the response. Cancelling in the browser does not cancel a recording already sent — the provider still transcribes it, bills the organization's account for it, and the call is recorded.

On Mistral the browser records WAV always, and fewer languages are transcribed

Mistral documents WAV and MP3 input for Voxtral, not WebM or Opus, which is what Chrome and Firefox record. So when the provider a recording resolves to is Mistral, the composer records 16 kHz mono 16-bit WAV in every browser even where the deployment accepts compressed audio. A PCM upload is about twice the size of the WebM it replaces - 32,000 bytes a second - and the length limit is the same.

The language list is Whisper's, which OpenAI and Groq accept. Mistral states 13 languages for Voxtral, so a pinned language it does not take comes back as provider_error. On Mistral, leave the language on auto-detect and try a recording before relying on it.

Which engine a recording reaches

Three places can name a speech-to-text engine: the organization's Voice card, an agent's speech_to_text override, set on its own Voice card and live once published, and a channel bot. They resolve in two halves:

What Comes from the first that sets it
Provider, model and key, together The bot, then the agent, then the organization
Language The agent, then the organization, then the engine detects it

The key travels with its pair. Each level names a key next to its own provider and model, and the three resolve as one: an agent that sets its own pair sends it with its own key, and never with the organization's. So a key is never sent to a provider it was not issued by. Which keys a level may name:

Level The key must be
The organization's default An API key for the provider, visible to the whole organization - the platform spends it for everybody. Checked when the Voice card is saved, and again per recording
A channel bot's own pair The same: organization-visible, because a bot's voice notes are paid for on behalf of whoever writes to it
An agent's override An API key for the provider that the publisher can view, the rule every key an agent spec names follows. Checked at publish

A key made private after it was chosen at the organization or on a bot stops being used, rather than going on paying for other people's recordings. A deleted key empties the reference at those two levels, and an agent's spec keeps the id until it is republished with another: either way the Voice card and the readiness check say "choose a key", and recordings are refused as not_configured until somebody does.

So a Polish agent in an organization set up for English pins language: pl and keeps the organization's provider and key. A bot has no language of its own; the agent's or the organization's applies to its own pair too. Nothing resolving means nothing is transcribed: dictation answers not_configured, and a bot says it cannot listen.

Dictation reads the agent's published default version, the one /chat runs. A bot reads the agent its binding serves - never an agent somebody mentions with @slug, because a recording is transcribed before mentions are routed - at the version that binding's environment pins, the one the run will use. A draft or archived agent has no version anybody runs, and the organization answers alone.

Personal data detection

POST /api/v1/ml/privacy/pii takes JSON rather than a file:

curl -X POST "$BASE/api/v1/ml/privacy/pii" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Organization-Id: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{"text": "write to ada@example.com", "categories": ["email"]}'
{
  "counts": [{"category": "email", "count": 1}],
  "total": 1,
  "redacted_text": "write to [redacted:email]"
}

Every category asked for is reported, including the ones that matched nothing — "looked for and absent" and "not looked for" are different answers. The categories are email, iban, credit_card, us_ssn and phone, and each is shape-matched and then checked: Luhn for a card, ISO 7064 for an IBAN, the country's numbering plan and digit grouping for a phone number, so a run of digits is not reported as an account or a number.

A phone number written with + is found whatever its country; a national one, such as 415-555-0132, is read against the guardrails' default countries, US, GB, DE, PL. The phone_regions field that changes that list belongs to an agent's guardrails, not to this endpoint.

What comes back is counts and the redacted text, not the offsets of each match. The detectors answer with rewritten text, and recovering positions would mean copying their pattern table and their checksums — a copy that silently stops agreeing with the original is worse than a narrower contract.

What is recorded

Every call leaves a row: which service, which organization, who asked, how many bytes went in, how much came out, how long it took and how it ended. GET /api/v1/ml/calls reads them back, newest first, and GET /api/v1/ml/calls/{id} reads one.

A refused call is recorded too, and its record is committed before the refusal unwinds the request — a row merely added to that transaction would be rolled back by the very refusal it describes, and an operator asking why an integration is failing would be told the tenant made no calls at all.

No content is kept. The result goes back in the response and is not stored, so a document parsed here does not become a document this deployment holds, and text sent to be scanned for personal data is not retained in a table nobody thought of as a document store. A call record another organization made is not found, the same way every other row on this API is scoped.

Usage is counted in the unit the service works in — pages for a parse, characters for a scan, seconds of audio for a transcription — and not in money. The delivered services either run on the operator's own machines, where there is no vendor price, or on the organization's own provider account, which bills it directly. A figure nobody can reconcile against an invoice is worse than an honest unit count. Transcription is not counted against an agent's budget either.

Transcriptions

Speech to text is reached three ways, and each leaves the same row: the API (surface: api), dictation (chat) and a channel bot's voice messages (channel). The row adds the provider and model, the secret_id of the vault key it was sent with - as a run records its key, so "which key costs most" has one answer - the agent_id it was for - the dictated-to agent, or the agent the bot serves - the channel_bot_id, and audio_ms, the recording's length.

Deleting the key leaves the row and empties its secret_id. A voice message's requested_by is the person the sender is linked to while that person is still a member of the organization, or nobody. Only a recording handed to the provider is recorded; a bot switched off, or one with no engine to inherit, asked nobody.

The length is the first of these that is known:

Source Who gives it
The recording itself Dictation only: the length read from its container, as above
The provider's answer OpenAI whisper-1 (usage.seconds), Groq (duration, asked for with response_format=verbose_json), Mistral (usage.prompt_audio_seconds)
The platform's metadata A Telegram voice note or audio file, a Slack clip
The browser Dictation only: the duration the composer sends, clamped to the organization's longest dictation, because it is the client's word

OpenAI's gpt-4o-transcribe and gpt-4o-mini-transcribe report tokens, not seconds, and Mattermost states no length, so a voice message there on those models has none. A successful row then counts 0 seconds with a null audio_ms - unmeasured, not silent.

GET /api/v1/ml/calls narrows by agent_id, channel_bot_id, surface, since and until as well as ml_service, always inside the caller's organization: another tenant's agent id matches nothing. The agent, the bot and the surface - as filters and as columns - need runs:view, like the summary below. With ml:invoke alone a filter on them is refused with 403, and the three come back null on every row.

GET /api/v1/ml/calls/summary?days=30 adds up the successful transcriptions over a window: seconds - each row's own units, the recording rounded up to the second, added up, so the summary agrees with the listing - and count, count_unknown_length for the recordings the seconds leave out, and the same by agent, by channel bot and by surface. It needs runs:view, as GET /spend does, rather than ml:invoke: the breakdown names agents and bots a Member may not see. The organization's Voice card shows it in one line.

Limits

A single call accepts up to ML_MAX_UPLOAD_SIZE_MB megabytes, 25 by default, and only that many bytes are read out of the body — an over-large submission is refused without having been copied into memory first. One scan reads at most 200000 characters, and a scan that includes phone at most 10,000 digits: the phone matcher costs tens of microseconds a digit, so a longer text is refused rather than read. A caller may make RATE_LIMIT_ML_PER_MINUTE calls a minute, 30 by default, counted per caller rather than per address.

A rate limit counts starts and cannot see what is still running, which is the wrong shape for work measured in minutes. So a worker also parses at most ML_MAX_CONCURRENT_PARSES documents at once, 4 by default, and a call arriving with every slot busy is refused with a Retry-After rather than queued: a caller told to come back in a moment can, and one parked behind four scans has already given up somewhere nobody here can see.

Execution is synchronous: the answer is the result, and there is no queue to poll. That is honest about what is here rather than aspirational — a queued mode would add its own states to the call record, which is the shape it would arrive in.

Deploying them separately

The services scale differently from the console. An OCR pass is CPU-bound seconds on a thread; serving a dashboard is neither. So deploy/profiles/ml-services/ runs the API image a second time as a replica that answers only these paths, with its own resources and its own scaling, and the ingress in front routes /api/v1/ml/ to it.

It is the same image and the same database, which is what keeps one implementation serving both the agents and the direct callers. What is separate is the process, the limits and the restart — which is what "deployed, updated and scaled independently" asks for.

See deploy/profiles/ml-services/README.md for the overlay and what it expects.