MCP¶
ailoud mcp serves your library to an AI agent over
MCP. The agent can search it, summarise it
and tag it.
Set it up¶
The quickest way is to let AILoud do it:
ailoud mcp install
It asks which agents to configure, with the ones it found on your machine pre-selected:
Which agents should AILoud configure?
Claude Code (detected)
Codex CLI (detected)
opencode (detected)
Gemini CLI (not found)
Hermes Agent (not found) -- global only
GitHub Copilot CLI (detected) -- global only
Then where to put it:
Where should it be configured?
This project only config files in this directory
Globally every project on this machine
Non-interactively:
ailoud mcp install --target claude,codex --location local
ailoud mcp install --target auto --location global --yes
It writes two things per agent: the MCP registration, and a rules block in the
agent's instructions file so it uses the tools well. The block is delimited by
<!-- AILOUD_START --> and <!-- AILOUD_END -->; nothing outside the markers
is touched, and installing twice changes no bytes.
For a per-project install it also creates .ailoud/, where that project's
recordings live. See the project library.
Update and remove¶
ailoud mcp update # refresh the block after upgrading AILoud
ailoud mcp uninstall # remove the registration and the block
ailoud mcp uninstall --target claude --location local
update touches only agents that are already configured; it never adds a new
one. uninstall deletes a config file AILoud created, edits one that holds
other servers, and leaves .ailoud/ alone.
Your own files are safe:
- Only the text between the two markers is ever rewritten. A file that merely
mentions
<!-- AILOUD_START -->in prose keeps everything around it. - A
config.tomlthat already defines anailoudserver some other way is refused, not edited. Two definitions of one key is a TOML error, and it would break your whole Codex config rather than just this server. - A Hermes
config.yamlholding your own settings or comments is rewritten, never deleted. Only a file with nothing but AILoud's keys in it is removed. - A trailing comma or a byte-order mark in a
.jsoncis tolerated. A file that is not JSON at all is refused with a message rather than rewritten.
Note
Comments in a JSON or .jsonc MCP config do not survive an edit -- the
file is parsed and re-serialised. TOML and YAML keep theirs.
Supported agents¶
| Agent | Scopes | Config | Rules file |
|---|---|---|---|
claude |
project, global | .mcp.json / ~/.claude.json |
.claude/CLAUDE.md, CLAUDE.md |
codex |
project, global | .codex/config.toml |
AGENTS.md |
opencode |
project, global | opencode.jsonc |
AGENTS.md |
gemini |
project, global | .gemini/settings.json |
GEMINI.md |
hermes |
global only | ~/.hermes/config.yaml |
~/.hermes/AGENTS.md |
copilot |
global only | ~/.copilot/mcp-config.json |
~/.copilot/copilot-instructions.md |
Running ailoud without an approval prompt¶
The rules block tells an agent to reach for ailoud audio search and its
neighbours. Most agents ask for approval before running a command, every time.
mcp install offers to add ailoud to the agent's allow-list so it does not
have to.
The prompt appears during an interactive install, after the location question,
and lists the exact files it would edit. --allow-shell and --no-allow-shell
answer it without a prompt.
-y on its own grants nothing. It means "do not prompt", and an unasked
permission question is not the same as one answered yes. Use -y --allow-shell
to ask for the allow-list in a script.
| Agent | File | Entry |
|---|---|---|
| Claude Code | .claude/settings.json, or ~/.claude/settings.json |
permissions.allow: ["Bash(ailoud:*)"] |
| Codex CLI | ~/.codex/policy.yaml |
allow: ["ailoud", "ailoud *"] |
| opencode | opencode.jsonc, or ~/.config/opencode/opencode.jsonc |
permission.bash: {"ailoud": "allow", "ailoud *": "allow"} |
| Gemini CLI | .gemini/settings.json, or ~/.gemini/settings.json |
tools.allowed: ["run_shell_command(ailoud)"] |
| GitHub Copilot CLI | ~/.copilot/permissions-config.json |
a commands approval for this directory |
| Hermes Agent | -- | Hermes records approvals itself; nothing to write |
Codex keeps one policy file for the machine even for a per-project install, and Copilot scopes its approval to the directory you ran the install in -- which the install says on its own line, because the file it writes is machine-wide.
Note
The rewrite rule above applies to these files too: comments in
.claude/settings.json, .gemini/settings.json, opencode.jsonc and
~/.copilot/permissions-config.json do not survive an edit. Codex's
policy.yaml keeps its comments, including any written inside the allow
list itself.
mcp uninstall removes the entry. mcp update refreshes one that is already
there and never adds one, which is why ailoud self sync cannot widen an
agent's permissions while sweeping your projects.
The project library¶
A directory named .ailoud/ makes that project's recordings separate from
your personal collection. AILoud finds it by walking up from the working
directory, the way git finds .git, so it works from any subdirectory.
ailoud mcp install --location local # creates it
ailoud doctor # shows which library is in use
The directory carries a .gitignore that excludes its own contents, so the
database and the media copies never reach git while the directory itself can
be committed.
The config file stays per-user either way. It names installed binaries and model files, which are not a property of a project.
By hand¶
.mcp.json in your project, ~/.claude.json for every project, or
claude_desktop_config.json for Claude Desktop:
{
"mcpServers": {
"ailoud": { "command": "ailoud", "args": ["mcp"] }
}
}
opencode.jsonc:
{
"mcp": {
"ailoud": {
"type": "local",
"command": ["ailoud", "mcp"],
"enabled": true
}
}
}
Run ailoud mcp and speak MCP over stdio.
Check it works:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' | ailoud mcp
It serves the same library the CLI uses, in both directions.
Ask it things¶
Which of my standups mentioned the rollback?
Summarise last week's 1:1 with Ben. He is my report.
Tag the untagged recordings for me.
Tools¶
Reading
| Tool | Returns |
|---|---|
list_recordings |
the library, with tags and whether a transcript exists |
list_untagged |
recordings that cannot be filtered by context yet |
list_tags |
every tag, with counts |
search_transcripts |
matching lines with timestamps and speakers |
get_transcript |
a file path, not the text |
list_speakers |
who spoke, and their names |
list_reports |
saved summaries |
get_report |
a file path |
list_templates |
the summary shapes available |
job_status |
a transcribe or summarize job's state |
Writing
| Tool | Does |
|---|---|
annotate |
titles, notes, tags, speaker names |
import_recording |
adds files to the library |
transcribe |
starts speech-to-text, in the background |
summarize |
starts a report, in the background |
create_template |
adds a summary shape |
Deleting
| Tool | Does |
|---|---|
delete_recording |
two calls; see below |
delete_report |
two calls |
Background jobs¶
transcribe refuses without speakers and languages¶
The first call without them gets this instead of a job id:
{
"error": "transcribe needs the speaker count and the expected languages",
"why": "declared languages stop whisper reporting Polish for a Russian stretch, which then comes back as phonetic nonsense; a known speaker count is more reliable than letting the diarizer infer one",
"guess": { "languages": ["ru", "en"], "from": "filename \"standup-ru-en.wav\"" },
"ask": "Ask the user how many people speak on this recording and in which languages. Offer the guess above, plus your own reading of the name, and let them correct it. Ask per recording when the recordings differ.",
"then": "call transcribe again with speakers and languages"
}
guess is null when the recording's name, title and tags give no hint.
speakers accepts a positive integer or "unknown"; languages accepts
codes such as ["ru", "en"] or ["auto"].
The job cycle¶
transcribe and summarize return at once, with a job id to poll:
transcribe(recordingIds: [...], speakers: 2, languages: ["ru", "en"])
-> { "jobId": "01M1Y5F04PS6VQ0FCP8HAS2JZ9", "kind": "transcribe",
"poll": "call job_status with this id; a few minutes apart is often enough" }
job_status(jobId: "01M1Y5F04PS6VQ0FCP8HAS2JZ9")
-> { "state": "running", "percent": 46, "stage": "detecting", ... }
Poll every minute or two; polling faster does not make the work finish sooner.
With no jobId, job_status lists what is running plus the five most recent
finished jobs.
A finished transcription that diarized speakers reports the labels nobody has named yet, so an agent can offer to name them while the transcript is in front of it:
job_status(jobId: "01M1Y5F04PS6VQ0FCP8HAS2JZ9")
-> { "state": "done", "unnamedSpeakers": [
{ "recordingId": "01M1...", "labels": ["speaker_00", "speaker_01"] }],
"nextStep": "... record it with `annotate` (speakerNames) ..." }
Only a person knows which label is which. A name given once survives re-transcription and is used by every later summary.
An id job_status does not recognise comes back on the same isError channel
a thrown refusal uses, but the payload itself distinguishes it from a job that
ran and failed -- error names the id as unrecognised rather than describing
a failure, and hint says what to do next:
{ "error": "no such job: nosuchjob", "hint": "call job_status with no id to list" }
The state document:
| Field | Meaning |
|---|---|
id |
the job id |
kind |
transcribe or summarize |
state |
running, done or failed |
percent |
0-100, approximate, never goes backwards |
stage |
what it is doing right now, e.g. detecting, transcribing |
etaSeconds |
present once there is enough of the run to estimate from |
pid |
the process id doing the work; job rm names it if it is still running |
recordings |
{ total, done } |
declared |
the speakers and languages given to transcribe, or null |
startedAt |
when the job began, ISO 8601 |
finishedAt |
when it ended, ISO 8601, or null while running |
log |
a file path, not the log text |
result |
set on success; a finished summarize carries reportId |
error |
one message, set on failure |
log is a path because it is a growing trail of stage transitions and
warnings over what can be an hour-long run, and it is only worth reading
after something fails -- and even then, error above already carries the
failure message.
A finished summarize job's result carries a reportId; read it with
get_report.
How it behaves¶
The server tells the agent six rules before its first call.
Tag everything. Tags are the only way to ask for "the recordings about this
project". list_recordings flags untagged ones and counts them, and
list_untagged exists so an agent can offer to fix them.
Search before reading. search_transcripts answers "where was this
discussed" in a few hundred bytes. Reading a transcript costs thousands of
tokens.
Transcripts arrive as files. get_transcript writes a temporary file and
returns the path, the line count and the duration. The agent reads the part it
needs with its own tools. The directory is removed when the server stops.
Pick a template for a summary. list_templates shows the shapes on
offer; the default meeting shape answers a one-to-one badly, so check the
list before summarising.
Context lives in the agent's memory. summarize takes a short context;
AILoud does not remember it between calls. The agent keeps it and passes it
again.
Transcribing refuses without speakers and languages. transcribe will
not start until the agent declares both; see Background
jobs for the refusal itself and what it returns instead.
Deleting takes two calls¶
The first call deletes nothing. It describes what would go and returns a token:
{
"status": "confirmation required",
"willDelete": [{ "id": "01M1B2...", "title": "Backend standup", "tags": ["standup"] }],
"notDeleted": "the original files these were imported from",
"recoverable": false,
"confirmationToken": "6f1c...",
"nextStep": "Show willDelete to the user. If they agree, call again with this confirmationToken."
}
Only a second call carrying that token deletes anything.
The token is single-use, expires in ten minutes, lives only in the server
process, and is bound to the exact ids it was issued for. The tools also carry
destructiveHint, so a client that gates destructive tools gates these.
Prompts¶
| Prompt | Does |
|---|---|
catch-up |
answers a question the cheap way: search, then read only what it points at |
tidy-library |
finds untagged recordings and proposes tags |
summarise-properly |
picks a template, checks for an existing report, passes context |
Resources¶
Transcripts and reports are addressable, with id completion:
ailoud://recording/{id}/transcript
ailoud://report/{id}
For clients that let you attach context directly.