Docs AI Components (Transcription, Voice, Semantic Search)

AI Components — Coding, Transcription, Voice & Semantic Search

Mockarty ships four optional self-hosted components that unlock AI-adjacent features. Each one runs next to the admin node. No Mockarty cloud account is required; for a fully air-gapped installation, mirror the component images and models and point the coding runner at a model endpoint inside the same environment.

Component Binary Unlocks
Autonomous coding mockarty-coder-runner Isolated implementation and verification work for autonomous missions
Speech-to-text mockarty-whisper-runner Transcripts for voice messages and meeting recordings in Discussions, plus the AI meeting digest
Voice synthesis mockarty-tts-runner The server-side Speak action — a shared voice reply posted into the thread for everyone
Semantic search mockarty-rag-runner Semantic (meaning-based) search, “find similar” for test cases and wiki pages, and vector features across the Knowledge Base

Speech, voice and semantic features degrade gracefully when their component is absent. Autonomous missions remain visible when no coder capacity is connected, so an operator can see and fix the missing runner instead of losing the mission.


How components connect

Every component authenticates to the admin node with an API token (create one in the UI: Settings → API Tokens, or ask your administrator) and identifies itself on start. Two environment variables are always required:

MOCKARTY_URL=http://your-mockarty:5770     # admin node base URL
MOCKARTY_API_TOKEN=<api-token>

Namespace scoping. By default a component with an administrator token serves the whole installation (a shared fleet). Set RUNNER_NAMESPACE=<name> to dedicate it to a single namespace — useful when one team’s traffic should not compete with others. A token that is pinned to a namespace automatically restricts the component to that namespace.

Components send a heartbeat every 30 seconds and deregister on clean shutdown, so the admin’s component lists always reflect reality.

How work reaches a component

By default a component asks the admin node for work: it holds a long request open and starts the job as soon as one appears. This needs no inbound network access, so it works behind NAT, in CI, and on a laptop.

If the admin node can reach the component over the network, you can additionally let it hand the job over directly, which removes the wait between “job created” and “component notices it”. Both ways stay active at the same time: a direct hand-over is tried first, and anything that cannot be delivered — the component is already busy, or unreachable — simply stays in the queue and is picked up the usual way. Nothing is ever lost, and you can turn it off at any time by removing the variables.

To enable it, give the component a port to listen on, a reachable host name, and a shared secret that the admin node also has:

Variable Where Meaning
RUNNER_PUSH_ADDR component Port to accept jobs on, e.g. :8790. Empty = ask-only (the default)
RUNNER_ADVERTISE_HOST component Host name the admin node uses to reach it, e.g. tts-1. Empty = the component does not offer direct hand-over
RUNNER_PUSH_SECRET component Shared secret. Falls back to MOCKARTY_A2A_SECRET so one fleet-wide value is enough
RUNNER_STATE_DIR component Directory for the durable accepted-job journal. Mount it on persistent storage when direct hand-over is enabled
RUNNER_STATE_ID component Stable identity for that journal across process or pod replacement. It must identify exactly one advertised push endpoint
MOCKARTY_A2A_SECRET admin node The same secret. Unset = the admin node never hands jobs over directly

The component also answers GET /healthz on that port — handy as a Kubernetes liveness probe. A component never accepts more jobs at once than its RUNNER_CONCURRENCY: extra hand-overs are declined and those jobs wait in the queue instead.

Before answering that it accepted a directly handed-over transcription or
synthesis job, the component records the job identity under RUNNER_STATE_DIR.
Keep that directory on a persistent volume: after a process restart the
component can then recognize the same job and will not perform paid work twice.
The first start creates an empty journal. A corrupt or unwritable journal makes
the push listener fail closed instead of accepting work without durable
evidence, so preserve the state volume across replacements. Pull-only mode does
not use this journal. Set RUNNER_STATE_ID to a stable value that survives pod
replacement. One advertised push address must lead to exactly one runner
journal; do not put multiple push replicas behind a load-balanced address. The
Helm chart enforces replicaCount: 1 while this push lane is enabled.


Autonomous coding runner

mockarty-coder-runner executes the coding steps of autonomous missions. Pull mode is the normal on-premises setup: the runner makes outbound requests to the admin node, advertises only the engine and sandbox selected for that process, accepts work only while it has capacity, and reports a durable terminal result before deregistering. Push mode is available for installations where the admin node can reach the runner.

Trust setup

The admin node signs short-lived task attestations and the runner verifies them with the paired public key. The two bindings must match exactly:

  • set the admin node’s MOCKARTY_CODER_ADMIN_URL to its stable public base URL;
  • start every runner with the same URL in --admin-url and a stable --runner-id;
  • keep the admin signing seed/private key in a Kubernetes Secret or another secret manager as MOCKARTY_CODER_ATTEST_KEY;
  • build the runner image with only the paired public key: --build-arg CODER_ATTEST_PUBLIC_KEY_B64=<base64-ed25519-public-key>.

Never put the signing seed/private key into the runner image, a build argument, or runner environment. A source-only development build without an embedded public key fails closed. MOCKARTY_CODER_ALLOW_DEV_OPEN=1 opens it only for an isolated local development run and must never be used in a shared or production environment.

Without the key, a production node signs nothing. With ENV=production and no MOCKARTY_CODER_ATTEST_KEY, the coder routes still answer, but runner registration and heartbeat are refused with a message naming the variable. This is deliberate: a key generated on the fly would change on every restart, so attestations issued before one would stop verifying after it — and a runner built with a pinned public key would never accept them at all. An install that does not run a coder needs no key and is unaffected. On a development machine (ENV unset or anything other than production) a temporary key is generated so the fleet can be worked on locally.

For Helm, keep the admin URL in values and inject the signing seed from a Secret:

admin:
  env:
    MOCKARTY_CODER_ADMIN_URL: "https://mockarty.company.example"
  extraEnvFrom:
    - secretRef:
        name: mockarty-coder-attestation # key: MOCKARTY_CODER_ATTEST_KEY

Pull-mode start

Create a namespace-scoped API token in Settings → API Tokens, choose a separate runner transport secret, and start the image:

docker run --rm \
  -v mockarty-coder-state:/home/coder/.mockarty-coder \
  -e MOCKARTY_CODER_TOKEN=<namespace-api-token> \
  -e MOCKARTY_CODER_SECRET=<runner-transport-secret> \
  -e DEEPSEEK_API_KEY=<model-key> \
  ghcr.io/your-org/mockarty-coder-runner:version \
  --pull --admin-url https://mockarty.company.example \
  --namespace my-team --runner-id coder-01 \
  --engine opencode --sandbox local

Keep the work-root volume across restarts: it contains pending result deliveries and bounded exact-replay receipts, not only temporary checkouts. Use --sandbox docker for untrusted tasks when the runner host provides Docker. The runner advertises only the selected sandbox and engine, so the admin does not dispatch work for capabilities that this process cannot execute. --max-concurrent is bounded by the process CPU budget.

Docker isolation also requires a pre-pulled engine image in --image and access to a dedicated or rootless Docker daemon. The runner image contains the Docker CLI but no daemon. Mount the daemon socket into the runner container (normally at /var/run/docker.sock) and add the socket’s group ID with Docker’s --group-add; the runner fails before registration if the CLI or daemon is unavailable. A host Docker socket grants broad control over that host, so do not share a general-purpose production daemon with untrusted autonomous workloads. Anyone who controls that daemon can inspect a workload container’s environment; treat the daemon and its operators as part of the trusted secret boundary. Prefer a dedicated runner host or rootless daemon and restrict its network separately. The --sandbox local example above is intentionally unsandboxed and is suitable only for an isolated development runner.

When a coder task declares original files, the runner redeems them before the
engine starts. Configure the exact HTTPS POST endpoint in
MOCKARTY_CODER_MATERIAL_BROKER_URL and inject its Bearer service credential
through MOCKARTY_CODER_MATERIAL_BROKER_TOKEN. Neither value may be named by a
task as a model, repository, or workspace credential. The runner verifies each
declared size, SHA-256 digest, MIME type, and response body, stages the complete
set under .mockarty-materials/, and does not expose one-time handles to the
coding engine or its report. The broker must bind redemption to the supplied
task, lease generation, logical attempt, design digest, and plan digest.

Push-mode start

Push mode suits installations where the admin node can reach the runner directly: the admin hands a job to the runner the moment it is queued, instead of waiting for the runner’s next poll.

The runner still registers with the admin, so it needs the same namespace token as a pull runner, plus the address the admin will dial:

docker run --rm -p 7480:7480 \
  -v mockarty-coder-state:/home/coder/.mockarty-coder \
  -e MOCKARTY_CODER_TOKEN=<namespace-api-token> \
  -e MOCKARTY_CODER_SECRET=<runner-transport-secret> \
  -e DEEPSEEK_API_KEY=<model-key> \
  ghcr.io/your-org/mockarty-coder-runner:version \
  --listen 0.0.0.0:7480 --callback-url http://coder-01.internal:7480 \
  --admin-url https://mockarty.company.example \
  --namespace my-team --runner-id coder-01 \
  --engine opencode --sandbox local

--callback-url is the address the admin dials. When --listen already names a concrete host it is derived automatically; a wildcard bind such as 0.0.0.0:7480 has no dialable form, so the runner refuses to start without an explicit value rather than advertising an address nothing can reach.

The admin authenticates its outbound pushes with a credential it issues to this runner at registration; the runner adopts it automatically, and it is replaced on every re-registration. Nothing extra to configure.

A pushed job that the runner declines — it is at capacity, or the request never reached it — goes straight back to the queue, so a runner that also polls picks it up on its own. Results travel back over the same durable path pull mode uses, and SIGTERM still drains admitted work and deregisters the runner.

Repository credentials

A task may name a repository credential through repo.tokenEnv; it never carries the token value. The runner accepts that environment name only when MOCKARTY_CODER_REPO_TOKEN_BINDINGS binds the repository’s exact HTTPS origin to it:

MOCKARTY_CODER_REPO_TOKEN_BINDINGS="git.company.example=GITLAB_TOKEN,github.com=GH_TOKEN"

The format is host=ENV[,host=ENV]. For default HTTPS port 443, use only the host. For a non-default port, use host:port=ENV, for example git.company.example:8443=GITLAB_TOKEN. Matching is exact, repository URLs must use HTTPS, and one environment variable must not be rebound to another origin. Runner-internal credentials such as MOCKARTY_CODER_TOKEN, MOCKARTY_CODER_SECRET, the attestation signing key, and the Mockarty MCP key are never valid repository credentials.

The token is provided to Git through a private, short-lived askpass helper. It is not placed in process arguments or saved in .git/config.

Health and shutdown

Push mode exposes GET /healthz for liveness and GET /readyz for admission readiness. Readiness becomes unavailable as soon as draining starts, while liveness stays available until in-flight work has finished. Completed-task receipts are written atomically under the work root. If the same queue attempt is physically redelivered after a lost response or process restart, the runner sends the cached result instead of repeating the paid task; an explicit new retry remains a new execution even when its task text is unchanged. A result saved immediately before a crash is delivered before the restarted runner accepts more work, and push mode keeps retrying a durable pending result without requiring another restart. Pull mode also stops claiming work when heartbeat/lease safety or durable result delivery is uncertain. Shutdown budgets follow the longest admitted task timeout plus result-delivery time; the runner does not report a clean deregistration while paid work remains unresolved.


Speech-to-text (transcription)

When at least one transcription component is live, Discussions gain:

  • automatic text transcripts under voice messages,
  • transcripts + an AI digest (decisions, questions, action items) for meeting recordings.

Audio travels only between the component and the admin node.

Long recordings (meetings) are transcribed segment by segment, and the text
grows on the transcription job as each segment is recognised — you do not have
to wait for the whole file before reading the first minutes. The finished
transcript is still posted once, complete, under the recording.

Run with Docker Compose

A ready compose file ships with the distribution (deploy/compose/docker-compose.whisper-runner.yml). The image bakes in a small multilingual model, so the minimal start is two variables:

MOCKARTY_URL=http://admin:5770 MOCKARTY_API_TOKEN=<token> \
  docker compose -f docker-compose.whisper-runner.yml up -d

Dedicate a pool to one namespace and scale it:

MOCKARTY_URL=... MOCKARTY_API_TOKEN=... RUNNER_NAMESPACE=my-team \
  docker compose -f docker-compose.whisper-runner.yml up -d --scale whisper-runner=2

Use a different recognition model without rebuilding the image — mount a directory with a GGML model file:

WHISPER_MODEL_DIR=/srv/models WHISPER_MODEL_FILE=ggml-large-v3-turbo-q5_0.bin \
  MOCKARTY_URL=... MOCKARTY_API_TOKEN=... \
  docker compose -f docker-compose.whisper-runner.yml up -d

Run in Kubernetes (Helm)

The Mockarty Helm chart includes the component — enable it in your values:

whisperRunner:
  enabled: true
  namespace: "sandbox"     # namespace it serves
  apiToken: ""             # inline for dev; use existingSecret in production
  existingSecret: ""       # Secret holding API_TOKEN

Run the binary directly

The component wraps a local whisper-server process (from the whisper.cpp project) — install it and point the runner at a GGML model:

Variable Required Meaning
MOCKARTY_URL yes Admin node base URL
MOCKARTY_API_TOKEN yes API token
WHISPER_MODEL_PATH yes Path to the GGML model file
WHISPER_SERVER_BIN no Path to whisper-server (default: found on PATH)
WHISPER_LANGUAGES no Languages to advertise (default auto)
RUNNER_NAME no Display name (default whisper-<hostname>)
RUNNER_NAMESPACE no Serve only this namespace (empty = shared fleet)
RUNNER_CONCURRENCY no Parallel jobs (default 1)
WHISPER_TRIM_SILENCE no 1 = pre-trim silence with ffmpeg for faster jobs
RUNNER_PUSH_ADDR no Accept jobs handed over directly (see How work reaches a component)
RUNNER_ADVERTISE_HOST no Host name the admin node uses to reach this component
RUNNER_PUSH_SECRET no Shared secret for direct hand-over (falls back to MOCKARTY_A2A_SECRET)
MOCKARTY_URL=http://localhost:5770 MOCKARTY_API_TOKEN=<token> \
  WHISPER_MODEL_PATH=/models/ggml-small-q5_1.bin \
  ./mockarty-whisper-runner

For CI-style ephemeral use, --once processes a single job and exits.

Alternative: external transcription API

If you already operate a speech-to-text service with an OpenAI-compatible audio endpoint, skip the component and point the admin node at it:

MOCKARTY_TRANSCRIBE_URL=https://stt.internal/v1/audio/transcriptions
MOCKARTY_TRANSCRIBE_KEY=<key>          # optional
MOCKARTY_TRANSCRIBE_MODEL=whisper-1    # optional

When both are present, live components take priority; the external API handles namespaces no component covers.


Voice synthesis (server-side Speak)

Text messages in Discussions always offer browser-side read-aloud (each person hears it locally). With a voice-synthesis component installed, the Speak action can also produce a shared voice reply — an audio message posted into the thread that everyone can play.

Run with Docker Compose

A ready compose file ships with the distribution (deploy/compose/docker-compose.tts-runner.yml). The image bakes in a Russian voice by default, so the minimal start is two variables:

MOCKARTY_URL=http://admin:5770 MOCKARTY_API_TOKEN=<token> \
  docker compose -f docker-compose.tts-runner.yml up -d

Dedicate a pool to one namespace and scale it:

MOCKARTY_URL=... MOCKARTY_API_TOKEN=... RUNNER_NAMESPACE=my-team \
  docker compose -f docker-compose.tts-runner.yml up -d --scale tts-runner=2

Use another voice without rebuilding the image — mount a directory with a Piper voice model (.onnx + its .onnx.json):

PIPER_VOICE_DIR=/srv/voices PIPER_VOICE_FILE=en_US-lessac-medium.onnx \
  MOCKARTY_URL=... MOCKARTY_API_TOKEN=... \
  docker compose -f docker-compose.tts-runner.yml up -d

Run in Kubernetes (Helm)

The Mockarty Helm chart includes the component — enable it in your values:

ttsRunner:
  enabled: true
  namespace: "sandbox"     # namespace it serves
  apiToken: ""             # inline for dev; use existingSecret in production
  existingSecret: ""       # Secret holding API_TOKEN

Run the binary directly

The component wraps the Piper speech synthesizer — install piper and download a voice model (.onnx):

Variable Required Meaning
MOCKARTY_URL yes Admin node base URL
MOCKARTY_API_TOKEN yes API token
PIPER_MODEL yes Path to the Piper voice model (.onnx)
PIPER_BIN no Path to piper (default: found on PATH)
PIPER_VOICE_NAME no Voice name to advertise (default: model file name)
RUNNER_NAME no Display name (default tts-<hostname>)
RUNNER_NAMESPACE no Serve only this namespace (empty = shared fleet)
RUNNER_CONCURRENCY no Parallel jobs (default 1)
RUNNER_PUSH_ADDR no Accept jobs handed over directly (see How work reaches a component)
RUNNER_ADVERTISE_HOST no Host name the admin node uses to reach this component
RUNNER_PUSH_SECRET no Shared secret for direct hand-over (falls back to MOCKARTY_A2A_SECRET)
MOCKARTY_URL=http://localhost:5770 MOCKARTY_API_TOKEN=<token> \
  PIPER_MODEL=/models/ru_RU-irina-medium.onnx \
  ./mockarty-tts-runner

--once is supported for ephemeral CI use. Without this component the shared-voice option is unavailable; local browser read-aloud keeps working.


Voice task defaults (per namespace)

A namespace can say two things about its automatic voice work — voice notes queued for transcription and replies queued for speech — in Settings → AI & Meetings:

  • Voice language — the default language (BCP-47, e.g. ru-RU) stamped on automatically queued voice tasks. A request that names its own language always wins.
  • Voice runner selector — comma-separated key=value labels a runner must advertise (for example region=eu,gpu=true). Leave empty to let any capable runner serve the namespace.

Both settings are constraints on where work goes: the queue skips a runner that does not match the selector (or the advertised language) and leaves the task for one that does. When no live runner matches, the task stays in the queue with a visible reason instead of being served by the wrong runner or hanging silently — add a runner whose advertised labels fit, and the queue picks it up.

The same settings are available over the API (GET/PUT /api/v1/namespaces/{namespace}/ai-settings, fields voiceLanguage and voiceRunnerSelector) and to agents through the namespace_voice_settings_get / namespace_voice_settings_update MCP tools.

With a semantic component registered, Mockarty adds meaning-based capabilities on top of regular keyword search:

  • Semantic search across product entities (cases, wiki pages, mocks…),
  • Find similar — near-duplicate detection for test cases, related wiki pages,
  • vector-backed Knowledge Base features.

Vectors are stored in Mockarty’s own database — the component is stateless compute. Killing or scaling it loses nothing.

Unlike the two components above, the admin node calls this component, so it must be reachable from the admin at a URL you advertise:

Variable Required Meaning
MOCKARTY_URL yes Admin node base URL
MOCKARTY_API_TOKEN yes API token
ADVERTISE_URL yes URL the admin uses to reach this component
EMBED_MODEL_PATH yes Path to the GGUF embedding model
EMBED_SECRET yes for network listeners Bearer secret the admin must present. It may be omitted only when LISTEN_ADDR is an explicit loopback IP
LISTEN_ADDR no Listen address (default :8090)
RUNNER_NAME no Display name (default rag-<hostname>)
RUNNER_NAMESPACE no Serve only this namespace (empty = shared fleet)
LLAMA_ARG_THREADS no llama.cpp worker threads (default: detected container CPU budget)
LLAMA_ARG_CTX_SIZE no Context size (default 2048)
LLAMA_ARG_BATCH no Physical batch size; must be at least the context size (default 2048)
LLAMA_ARG_UBATCH no Micro-batch size; must not exceed the physical batch (default 2048)
LLAMA_ARG_N_PARALLEL no Concurrent embedding slots (default: the smaller of 2 and the configured worker-thread budget)

The component wraps a local llama-server (from the llama.cpp project) in embeddings mode. GET /healthz is the liveness endpoint. GET /readyz returns ready only after the model is available and the component is registered with the admin node. Embedding requests must use POST /v1/embeddings with JSON and the configured Bearer secret. Requests over 8 MiB are rejected; when all configured slots are busy, the component returns 429 with Retry-After so callers can retry without overloading the model.

Run with Docker Compose

MOCKARTY_URL=http://admin:5770 MOCKARTY_API_TOKEN=<token> \
  ADVERTISE_URL=http://rag-runner:8090 EMBED_SECRET=<random-secret> \
docker compose -f docker-compose.rag-runner.yml up -d

Set RAG_RUNNER_IMAGE to the image mirrored into your registry. If Compose needs to build it locally, set RAG_MODEL_SHA256 to the verified SHA-256 of RAG_MODEL_URL; a missing or mismatched checksum stops the build. Set RAG_MODEL_MODE=NONE only when your own model is mounted at runtime: set EMBED_MODEL_DIR to its host directory and point EMBED_MODEL_PATH to /ext-models/<file>.

Run in Kubernetes (Helm)

ragRunner:
  enabled: true
  namespace: ""          # empty = shared; set a namespace for dedicated compute
  embedSecret: ""       # bearer the admin presents on embedding calls
  apiToken: ""          # inline for dev; use existingSecret in production
  existingSecret: ""    # Secret holding API_TOKEN + EMBED_SECRET

The chart wires ADVERTISE_URL to the in-cluster service automatically.

Alternative: external embedding API

Any OpenAI/Ollama/TEI-compatible embedding server works — point the admin node at it instead of running the component:

MOCKARTY_EMBED_URL=https://embeddings.internal/v1/embeddings
MOCKARTY_EMBED_KEY=<key>              # optional
MOCKARTY_EMBED_MODEL=<model-name>     # optional

Without a component and without MOCKARTY_EMBED_URL, semantic features stay hidden and regular keyword search keeps working.


Monitoring

Registered components are visible over the API:

curl -H "Authorization: Bearer <token>" http://localhost:5770/api/v1/transcribe/runners
curl -H "Authorization: Bearer <token>" http://localhost:5770/api/v1/transcribe/jobs
curl -H "Authorization: Bearer <token>" http://localhost:5770/api/v1/tts/runners
curl -H "Authorization: Bearer <token>" http://localhost:5770/api/v1/semantic/endpoints
curl -H "Authorization: Bearer <token>" http://localhost:5770/api/v1/semantic/status

Each entry carries the component’s name, model, namespace, last heartbeat and job counters — a component that stops heartbeating drops out of dispatch automatically.
After an admin restart removes a speech runner’s registration, the runner
registers again on its next heartbeat. A temporary network or server error
does not create another registration; check the runner list once the admin
recovers.

Jobs waiting on an unanswered runner

When Mockarty pushes a job to a component and the response never comes back —
a dropped connection, a timeout, a component that died mid-answer — the job is
held, not retried. The component may already be doing the work, and
transcription and synthesis both cost real money, so handing the same job to a
second component would pay for it twice.

Held jobs are listed on their own. Listing requires read access to the target
namespace; a namespace outside the caller’s scope is hidden:

curl -H "Authorization: Bearer <token>" http://localhost:5770/api/v1/transcribe/jobs/fenced
curl -H "Authorization: Bearer <token>" http://localhost:5770/api/v1/tts/jobs/fenced

Each entry names the component the job is held under and how long it has been
waiting. Nothing moves it until you say what happened — check the component,
then record the answer. Reconciliation requires write access to the target
namespace; viewers and auditors cannot settle paid work.

curl -X POST -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{"runnerId":"<component id>","outcome":"not_started"}' \
  http://localhost:5770/api/v1/transcribe/jobs/<job id>/reconcile-delivery
  • not_started — the component never began. The job goes back in the queue for
    another component and the failed delivery is not counted against its retries.
  • started — it did begin. The job is closed without being repeated.

An agent does the same through media_delivery_queue and
media_delivery_reconcile. Both record a fact you established; neither one
checks the component for you.

The CLI and all supported SDKs expose the same two operations:

mockarty-cli media-delivery queue --engine transcribe
mockarty-cli media-delivery reconcile <job-id> --engine transcribe \
  --runner-id <component-id> --outcome not_started
page, err := client.MediaDelivery().ListFenced(ctx, "transcribe")
err = client.MediaDelivery().Reconcile(ctx, "transcribe", jobID, runnerID, "not_started")
page = client.media_delivery.list_fenced("transcribe")
client.media_delivery.reconcile("transcribe", job_id, runner_id, "not_started")
Map<String, Object> page = client.mediaDelivery().listFenced("transcribe");
client.mediaDelivery().reconcile("transcribe", jobId, runnerId, "not_started");

See also