Skills reference
A skill is a folder with a prompt.md and a tools.py. This page is the complete reference for everything a skill can export and everything the framework gives it.
- TOOLS
- handle
- resolve_human
- is_available
- resolve_surface
- startup
- bind
- before_mcp_call
- observe_mcp_result
- tool_result_empty
- tool_target
- MCP_TOOLS
- VERIFY
- The ctx object
- Built-in skills
- Configuration overrides
- Loading skills
File structure
Section titled “File structure”skills/└── my_skill/ ├── prompt.md # required: injected into the system prompt └── tools.py # required: tool definitions and handlersBoth files are required. If either is missing, the skill fails to load.
prompt.md
Section titled “prompt.md”Plain markdown. Loaded at startup and injected into the LLM’s system prompt alongside all other skill prompts.
Write it from the AI’s perspective. Describe:
- What this skill can do
- When to use each tool
- Rules or constraints the AI should follow for this skill
Example:
You can check service health and trigger restarts.
Use `list_services` to enumerate available services.Use `check_health` to test an endpoint before reporting on it.Use `restart_service` when a service is unhealthy — this requires human approval in production.
Always verify health before recommending a restart.Report response times alongside status codes.tools.py exports
Section titled “tools.py exports”TOOLS (required)
Section titled “TOOLS (required)”A list of tool definitions in Anthropic tool schema format.
TOOLS = [ { "name": "check_health", # snake_case, unique within the skill "description": "Check whether a service endpoint is responding.", "input_schema": { "type": "object", "properties": { "url": { "type": "string", "description": "The URL to check", }, "timeout_ms": { "type": "integer", "description": "Request timeout in milliseconds. Default: 5000", }, }, "required": ["url"], }, "human": "approve", # optional: approval level (see below) "expose_result": True, # optional: include completed output in synchronous API triggers },]"human" values:
| Value | Behavior |
|---|---|
omitted or null |
Auto-execute |
"approve" |
Pause for Approve/Deny |
"confirm" |
Require typing a confirmation |
"dynamic" |
Defer to resolve_human at runtime |
Tool names are automatically prefixed: a tool named check_health in a skill named services is exposed to the LLM as services__check_health.
expose_result defaults to False. When set to True, a completed, approved native-tool
call is included in the response from POST /api/trigger?wait=true only when its handler
returns an explicit successful ToolExecutionResult:
from mithai.core.skill_loader import ToolExecutionResult
def handle(name, input, ctx): if name == "check_health": result = json.dumps({"status": 200, "healthy": True}) return ToolExecutionResult(content=result, succeeded=True)The API entry is {"tool": "services__check_health", "result": ...}. Valid JSON content is
decoded; other strings are retained. Return ToolExecutionResult(content=..., succeeded=False)
for a normal application failure so the content remains visible to the model but is not exposed
through the API. A plain string from an exposed tool is also model-visible but is not included in
tool_results, because Mithai cannot infer its success from arbitrary payload text. This is
fail-closed by design.
Tool inputs are never included. Denied, suppressed, failed, ambiguous plain-string, unexposed, and MCP tool calls are never returned. Async and streaming trigger shapes are unchanged.
handle(name, input, ctx) (required)
Section titled “handle(name, input, ctx) (required)”Called when the LLM invokes a tool. Normally returns a JSON-serializable string. A tool using
expose_result: true returns ToolExecutionResult to explicitly declare success or failure.
from mithai.core.skill_loader import ToolExecutionResult
def handle(name: str, input: dict, ctx: dict) -> str | ToolExecutionResult: if name == "check_health": url = input["url"] # ... do work ... return ToolExecutionResult( content=json.dumps({"status": 200, "healthy": True}), succeeded=True, )
return ToolExecutionResult( content=json.dumps({"error": f"unknown tool: {name}"}), succeeded=False, )Parameters:
name— the tool name (without the skill prefix)input— dict of inputs as provided by the LLM, validated against the schemactx— the context dict (see Thectxobject below)
Return value: A JSON string, or ToolExecutionResult for an explicitly exposed tool. The LLM
reads the contained string as the tool’s result in both cases. Return structured data where
possible — it’s easier for the LLM to reason about than free text.
For a tool recorded in the mutation ledger, a failed result settles
outcome_unknown by default, because Mithai cannot tell whether the write took effect. When the
handler has verified that it did not (for example, by reading the target back), return
ToolExecutionResult(content=..., succeeded=False, failure_confirmed=True) to settle failed
instead. failure_confirmed must be a bool and is rejected together with succeeded=True.
Always handle the unknown-tool case. Return an error JSON rather than raising an exception.
resolve_human(name, input, ctx) (optional)
Section titled “resolve_human(name, input, ctx) (optional)”Called before executing a tool to determine the approval level at runtime. Takes precedence over the static "human" key on the tool definition.
def resolve_human(name: str, input: dict, ctx: dict) -> str | None: if name == "restart_service": if input.get("environment") == "production": return "approve" return None # staging: auto-execute return None # everything else: auto-executeReturn values:
| Value | Behavior |
|---|---|
None |
Auto-execute |
"approve" |
Pause for Approve/Deny |
"confirm" |
Require typing a confirmation |
Use resolve_human when the approval level depends on the specific inputs, not just the tool name. Common patterns:
- Require approval only in production environments
- Require approval only above a certain threshold (e.g., scaling to >10 replicas)
- Allow known-safe values through automatically, flag unknown ones
is_available() (optional)
Section titled “is_available() (optional)”A synchronous, zero-argument hook that returns a boolean. Use it when a skill requires runtime configuration that may be absent:
import os
def is_available() -> bool: return bool(os.environ.get("SERVICE_API_KEY", "").strip())The loader calls this after importing tools.py, before registering the skill.
Returning False excludes its prompt, tools, and catalog entry. It also removes
any earlier skill with the same name from lower-priority skill paths. A later
available skill with that name can still override it. Skills without this hook,
including prompt-only skills, keep their existing behavior.
A non-callable hook, exception, or non-boolean result stops loading with a configuration error; exception details and returned values are omitted. Keep the hook quick and free of side effects. This is a load-time check, not a live credential refresh or a substitute for authorization in tool handlers. Reload the runtime after changing availability. Module import still runs, so avoid credential-dependent work at module scope.
mithai skill validate checks that the export is callable but does not invoke
it. Artifact validation therefore does not require runtime credentials and
still checks the skill’s prompt and tool definitions when unavailable.
resolve_surface(config, mcp_catalog) (optional)
Section titled “resolve_surface(config, mcp_catalog) (optional)”Available from 0.23.24. Use this hook when native tools or instructions depend on
instance configuration and discovered MCP capabilities. The engine calls it after
MCP startup and before tool indexing, allowlist locking, startup(), or bind().
It also runs before the next router read or dispatch after a catalog change.
from mithai.core.skill_loader import SkillSurface, ToolDefinition
TOOLS = [] # Required loader export; the hook supplies the complete native set.
def resolve_surface(config, mcp_catalog): capabilities = {tool.name for tool in mcp_catalog.get("service_api", [])} if config.get("authoring_enabled") is not True or not {"create", "patch"} <= capabilities: return SkillSurface(prompt="Service authoring is unavailable.") return SkillSurface( prompt="Use author to create or update a service definition.", description="Author service definitions with human approval.", tools=(ToolDefinition( name="author", description="Create or update a service definition", input_schema={"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}, human="approve", ),), )
def handle(name, arguments, ctx): # Implement the operation using ctx["config"] and instance-owned clients. ...config is the effective skills.config.<skill_name> dictionary, or {}.
mcp_catalog maps server names to lists of ToolDefinition, before direct-tool
and alias exposure filtering. Empty/missing servers have no capabilities. Compare
actual tool names; server version strings are not capability evidence. The catalog
intentionally does not distinguish failed discovery, an unhealthy connection, and
a healthy server without the required capability. Resolvers must treat all three
as unavailable; connection diagnostics remain the host’s responsibility. Restricting
direct MCP exposure does not prevent a native handler from calling that server.
The returned SkillSurface replaces the entire native prompt, description and tool
set; include any always-available native tools in every result. tools must be a
tuple of unique ToolDefinition objects. description=None derives the skill’s
short description from the resolved prompt. SkillSurface(prompt="") withdraws
both native instructions and native tools. Static MCP_TOOLS/MCP_SERVERS
declarations remain independent of this native hook.
Resolvers must be quick, synchronous, deterministic functions without I/O, engine binding, or mutations of module globals. They may run concurrently for independent engines and managed configuration snapshots. Inputs and returned definitions are copied per resolution; do not store tenant configuration or resolution results in process-global state. Runtime handlers and other hooks retain their existing binding/thread-safety contract. A resolver exception or invalid return disables that skill’s native prompt/tools and emits a warning without exception details. Later successful resolution restores eligible tools from the original loaded skill.
None, "approve", "confirm", and "dynamic" human policies are preserved.
For "dynamic", the existing resolve_human hook still runs; human config overrides
still apply. The router does not itself collect approvals: the engine does, and
standalone hosts must enforce their own human policy before dispatch.
Engines, managed snapshots, and refresh
Section titled “Engines, managed snapshots, and refresh”Normal Engine(...) construction supplies effective config automatically.
Hosts constructing routers from preloaded skills use the same lifecycle.
Metadata is captured at router construction. Mutating the original loaded
Skill.tools, a tool’s .human policy, Skill.prompt, or
engine._skills[name].tools afterward no longer updates routing or approval.
This also applies to hosts without resolve_surface. Configure static metadata
before constructing the router/Engine. For dynamic tools and policies, return
fresh definitions from resolve_surface and use the supported refresh API below;
router.refresh() does not reread mutated static source metadata. Runtime handler
and hook bindings retain their existing live binding behavior.
Hosts can inspect and dispatch from one snapshot:
from mithai.core.tool_router import ToolRouter
router = ToolRouter( skills, # Original loaded Skill objects mcp_manager=manager, # Already started, or None for config-only hooks skill_configs={"services": {"authoring_enabled": True}}, allowed_tools={"services__author"}, # Optional explicit upper bound)router.lock_allowlist()surface = router.snapshot()
prompts = {name: skill.prompt for name, skill in surface.skills.items()}tools = surface.collect_tools_for_llm()definition = surface.get_definition("services__author")allowed = surface.allowed_tools # frozenset; locking never broadens restrictionsrevision = surface.revision # Opaque identity, not a server version
# After enforcing approval against this surface's definition:result = surface.route("services__author", {"name": "checkout"}, context)Each snapshot keeps prompt, tools, definitions/approval metadata, dispatch indexes,
and effective allowlist together. Public metadata reads return independent copies.
Use one snapshot for related reads and dispatch after approval. Revocation is per
tool: its definition (including human policy), MCP destination, owning skill’s
prompt/description and config, and temporal guardrails must remain unchanged.
Unrelated catalog changes and identical refreshes preserve calls and prepared MCP
receipts. Observed tool withdrawal revokes old calls even if the tool returns with
an identical definition. A revoked call returns execution_status="not_executed",
reason="surface_changed"; obtain a new snapshot and repeat approval before retrying.
The snapshot’s revision identifies publication, not whether a particular call is
revoked. Calls already admitted
to a handler may finish. Refresh never holds the publication lock across handler
or transport I/O. Private index mutation is unsupported. Catalog snapshots currently
copy the full catalog on each read; measure this cost before using large catalogs.
manager.refresh_catalog(server_name) synchronously reconnects and rediscovers
both tools and resource proxies through the existing transport lifecycle. Call it
from a host thread, not the MCP event loop. It returns True on recovery, False
on ordinary connection failure; terminal transport cleanup errors still raise.
Capabilities are withdrawn before teardown and remain absent on failure.
Compatibility change for all MCP hosts, including those without a resolver:
quarantine, transport cleanup failure, degraded sessions, and reconnect withdraw
that server’s direct mcp__* tools and aliases from new model tool lists. Earlier
versions could retain their cached definitions while disconnected. Successful
rediscovery restores eligible tools within the locked allowlist. In-flight calls
to an unchanged tool on another server remain admissible.
Normal transport reconnect also updates the catalog generation automatically. Routers check the local catalog on access; they never perform network discovery themselves. Servers changing their tool list require a host refresh or reconnect; this API does not subscribe to server list-change notifications or schedule polling.
router.refresh(skill_configs=new_effective_configs) replaces the router’s config
snapshot and reevaluates hooks. The argument is a complete map, not a partial merge;
omitting it retains existing config. Explicit restrictions survive both refresh and
capability restoration. lock_allowlist() also freezes the currently allowed names
as a permanent upper bound, including when called again. A startup tool can be
withdrawn and later restored; a newly discovered tool cannot enter the locked set.
This includes native tools absent at startup because a capability was unavailable.
One exception: a configured server with no tools when the lock was taken (for example,
it refused the startup connect) is admitted once, on its first discovery. Names
dispatchable only because that server now has tools, including skill surfaces built
from its catalog, join the locked set as if the server had connected at startup.
Tools other servers added after the lock stay outside it. Create a new router or
Engine to admit other new tools. Engine dispatch looks tools up with
discover_surface(): a miss on a toolless server makes one connect attempt,
bounded by the turn’s deadline and cancellation, before reporting the tool unknown.
A tool found this way is approved against the snapshot it was found in, so a policy
change while approval waits still revokes it. Dispatch reads the definition and its
revision from that one snapshot: a tool another reconnect publishes after the lookup
is unknown to this call. get_definition() never connects. engine.refresh_skill_surfaces() retries resolution with
the engine’s current snapshot config, including a previously failing resolver whose
catalog has not changed. Host configuration changes should create a new Engine.
Each model turn uses one surface for its prompt and schemas. Calls to changed tools
are refused before and after human approval; unchanged tools can continue in the
same turn after an unrelated server reconnects. A later turn uses the new surface.
The lower-level resolve_skill_surfaces(skills, skill_configs, mcp_catalog) function
returns resolved Skill copies for inspection. Keep the originals for subsequent
resolution; use ToolRouter for indexing, refresh, allowlist and stale-call guards.
mithai skill validate checks that resolve_surface is callable without invoking it.
Use this hook instead of mutating module TOOLS in bind() or using is_available()
for tenant/catalog gates: is_available=False removes the skill at load time and
cannot recover through surface refresh.
startup(config) (optional)
Section titled “startup(config) (optional)”Called once when the engine starts, before any messages are processed. Use it for:
- Starting background polling loops
- Establishing persistent connections
- Validating configuration and failing fast if something is missing
import threading
def startup(config: dict) -> None: interval = config.get("poll_interval", 60) t = threading.Thread(target=_poll_loop, args=(interval,), daemon=True) t.start()config is the skill’s configuration block from config.yaml (the same as ctx["config"] in handlers).
bind(engine, adapter) (optional)
Section titled “bind(engine, adapter) (optional)”Called after both the engine and adapters are fully initialized. Use it when your skill needs a reference to the engine or to adapter-specific APIs.
_engine = None_adapter = None
def bind(engine, adapter) -> None: global _engine, _adapter _engine = engine _adapter = adapterThis is an escape hatch for advanced integrations (e.g., a skill that proactively sends messages when it detects an alert). Most skills don’t need it.
For deterministic composite tools, a skill may capture the read-only engine.mcp_manager
accessor here and use its call_tool(server_name, tool_name, arguments) method from
handle(). The accessor is None when no MCP servers are configured. These calls run
inside the native handler, so set its tool’s human policy to cover any required approval.
Use call_tool_with_status(server_name, tool_name, arguments) for the public
mithai.core.mcp_manager.MCPToolCallResult dataclass instead of content alone.
Its fields are content, is_error, coverage, error_from_server,
reference_url: str | None = None, and links: list[dict[str, str]] | None = None
(links is unreleased). Existing positional constructors retain their meaning. Use dataclasses.replace(result, content=...)
to reconstruct a result without dropping its metadata.
reference_url preserves the server’s CallToolResult._meta.reference_url
(the Python MCP SDK exposes _meta as meta; legacy _meta is also supported).
Extraction accepts nonempty strings of at most 4,096 Python characters, unchanged;
missing metadata, non-object metadata, wrong-shaped values, empty strings and
longer strings yield None, without truncation or URL synthesis from content IDs.
This field is independent of coverage and is preserved on server error results;
it does not establish that an operation succeeded. Locally synthesized errors
have no observed link. The content-only call_tool() API remains unchanged.
links preserves valid entries from _meta.links when it is a list. Each entry
must be an object with nonempty label and url strings, each at most 4,096
characters. Invalid entries are dropped; extra keys are omitted. Order and text
are preserved, and no valid entries yields None. This field is independent of
reference_url and is also preserved on server error results.
The approval-v2 tool_completed event forwards reference_url and links when
present, including for direct and skill-aliased MCP calls and guarded execution.
Absent or invalid metadata is omitted from the event. Result rewrites do not
change these observed links. See configuration.md.
The host must validate suitability before persisting or presenting the links,
including scheme, organization and application-path policy. Relative paths such
as /v2/organizations/example-org/dashboards/dashboard-42 are normal Last9 links;
Mithai does not require HTTPS, resolve paths, strip whitespace or sanitize URLs.
These fields do not automatically enter model content, mutation receipts or replay
state. Approval and logical-turn replay behavior are unchanged.
For the hosted read-only regression, set MITHAI_HOSTED_MCP_URL,
MITHAI_HOSTED_MCP_READ_TOOL, MITHAI_HOSTED_MCP_READ_ARGS_JSON and the expected
MITHAI_HOSTED_MCP_REFERENCE_URL, then run
python -m pytest tests/test_hosted_mcp_integration.py -k preserves_reference_url.
Use a read-only tool that returns the link in result metadata; the probe checks
that it reaches tool_completed. Optionally set MITHAI_HOSTED_MCP_LINKS_JSON
to the expected list of {label, url} objects to check that event field too.
The normal release
owner must publish a tagged release containing this API before dependent hosts
update their Mithai pin; it is not available in v0.23.31.
before_mcp_call(server_name, tool_name, arguments, ctx) (optional)
Section titled “before_mcp_call(server_name, tool_name, arguments, ctx) (optional)”The skill declaring the server in MCP_SERVERS supplies its pre-call policy hook.
It receives prepared arguments for direct MCP calls and skill aliases, and returns
an MCPPreflightDecision. Native skill tools do not invoke this hook.
Starting in 0.23.24, preparation runs before human approval. Corrections, prerequisites, malformed decisions, preparation timeouts, and cancellation do not prompt the human or execute the proposed call. A successful prerequisite still resumes the parent through fresh preparation and approval, as described below.
An MCP server’s owning skill can return an MCPPreflightDecision before dispatch.
To request a prerequisite, return kind="prerequisite" and an MCPPrerequisite
naming a read-only tool declared in the skill’s MCP_PREREQUISITES tuple. The tool
must be allowed, on the same server, and owned by the same skill. Matching reads
are deduplicated within the model’s tool batch.
After the prerequisite executes successfully, the engine retries the original
call once without another model turn. It reuses the prepared arguments (including
resolved time bounds), or the decision’s optional corrected_arguments dict as a
complete replacement ({} is valid). The retry goes through policy, approval,
temporal checks, cancellation, deadline and tool-budget admission again. Guarded
executors retain their frozen-scope checks. A failed or denied prerequisite returns
the original correction; a refused retry returns the new correction without
running another prerequisite. Eligible retries share the original batch’s bounded
worker concurrency, ordered budget admission, and result ordering.
The model receives the retry’s actual result plus a short
_mithai_prerequisite_retry note. JSON objects retain their fields; other results
are wrapped in a result field. Execution metadata records
retried_after_prerequisite=true; an executed retry’s tool span also carries
mithai.tool.retried_after_prerequisite=true. The model’s original tool-call ID
and proposal remain paired with this result.
Use get_tool_route_context(ctx).incoming_message to read attributes that the host
or a skill attached to the current turn’s message:
from mithai.core.context import get_tool_route_contextfrom mithai.core.mcp_preflight import MCPPreflightDecision
MCP_SERVERS = ["observability"]
def before_mcp_call(server_name, tool_name, arguments, ctx): route_ctx = get_tool_route_context(ctx) message = route_ctx.incoming_message if route_ctx is not None else None route = getattr(message, "route", None) # Apply the host's policy using the earlier classifier's decision. return MCPPreflightDecision(context={"route": route})See the routing context contract below for lifetime and concurrency rules. Decision context is model-visible; include only safe policy metadata.
Host-owned approval previews
Section titled “Host-owned approval previews”Behavior change for all hosts: the engine now runs the owning skill’s
before_mcp_call hook before asking for human approval, whether or not the hook
returns a preview. There is no opt-in flag. Read-only probes therefore run even
when the human later denies the call or the approval wait times out. A prerequisite
or correction decision short-circuits before the parent call is approved;
prerequisite reads follow their own policy, and a resumed parent is prepared and
approved afresh. Audit existing hooks for read-only, cancellation-aware behavior
before upgrading. See the release notes.
The owning hook may return an optional approval_preview JSON object alongside
its policy decision. This is host-produced evidence, separate from executable MCP
arguments and model-visible context. For example, after read-only probes have
validated every panel:
return MCPPreflightDecision( approval_preview={ "panels": [ {"title": "Traffic", "status": "verified", "rows": 3}, {"title": "Errors", "status": "verified", "rows": 0}, ] })The host owns the preview schema and the meaning of each result; mithai does not
verify host-supplied claims. The preview must accurately show the arguments that
will be applied, including the target and any destructive effects, subject to
presentation redaction. Include every panel, including empty results; return a correction with a safe context.reason
when validation fails. Do not copy model assertions into verified results. The
engine rejects a top-level approval_preview in executable arguments with
reserved_approval_preview, even if the MCP server defines a real argument with
that name. Such tools must rename that top-level argument before use with mithai.
The engine never forwards host preview fields to MCP.
Preparation hooks have a 30-second execution/admission bound, shortened by the
request deadline or cancellation. Probes must be read-only and honor
get_tool_route_context(ctx).cancel_event and
get_tool_route_context(ctx).current_deadline_monotonic() in their own I/O.
A cancelled or timed-out Python hook may finish later, but cannot issue a receipt
or authorize a write. Its worker slot and session hook marker remain held until
it exits. Failure reasons include preflight_failed, preflight_timeout,
deadline, and cancelled.
The host snapshots arguments and the redacted preview into the same single-use, request-local receipt. Altered arguments, previews, or receipt ownership fail closed; callers must dispatch again for fresh preparation and approval. Successful preparation is not repeated after the human approves. The engine still refreshes the execution deadline and delegated token, and checks cancellation before dispatch. Existing approval overrides and auto-approval policies continue to apply.
Previews must be JSON objects (None means absent; {} is a present preview).
The serialized object may contain at most 12,000 characters, both before and
after redaction, using json.dumps(..., ensure_ascii=False, allow_nan=False).
Oversized previews fail with approval_preview_too_large before approval; no
panels are silently discarded. Secret fields and keys containing a whole sql or
script token (case-insensitive, separated by underscores or hyphens) stay fully
redacted. Query fields, including PromQL and nested log/trace pipeline queries,
remain visible with secret-like substrings scrubbed. Incidental names such as
javascript and postgresql are not opaque. This is the same presentation
redaction used for tool arguments; argument copies retain their 2,500-character
cap (_truncated / _preview).
HumanRequest.approval_preview carries the redacted copy. API approval events
use this presentation-only tool_input envelope only when a preview exists:
{ "arguments": {"title": "Services"}, "approval_preview": {"panels": [{"title": "Traffic", "status": "verified", "rows": 3}]}}Arguments retain their existing presentation cap; the preview remains complete.
Slack labels it “Host-supplied preview” and shows the complete preview in
plain-text blocks before approval buttons.
Calls without a preview keep the existing flat API tool_input shape:
{"title": "Services"}Clients must handle both shapes. None means no preview and uses flat arguments;
{} is a present preview and uses the envelope. These are presentation copies,
not executable input. This contract requires no query_panel or
preview_dashboard MCP tools.
For whole-dashboard replacement, include an explicit warning and the removed panels in the host preview, for example:
MCPPreflightDecision(approval_preview={ "warning": "Replace the entire dashboard. Omitted panels will be removed.", "removed_panels": ["Latency"], "panels": [{"title": "Traffic", "status": "verified", "rows": 3}],})Require human approval for the replacement tool through its MCP declaration or
human.overrides. Use confirm for typed confirmation on API clients; Slack
presents the warning before its Approve/Deny buttons. The warning is presentation
text, not an approval policy: existing overrides and auto-approval prefixes still
apply, so do not auto-approve replacement tools that require a human decision.
observe_mcp_result(server_name, tool_name, arguments, result, ctx) (optional)
Section titled “observe_mcp_result(server_name, tool_name, arguments, result, ctx) (optional)”Called after a successful direct MCP tool read when this skill declares ownership of the
server in MCP_SERVERS. It can augment the string returned to the model without changing the
MCP request or its approval policy.
MCP_SERVERS = ["observability"]
def observe_mcp_result(server_name, tool_name, arguments, result, ctx): channel_id = ctx["channel_id"] return f"{result}\n\nRead for session {channel_id}."The hook receives the original arguments, successful result string, and the same ctx object
as the current tool call. Its return value replaces the model-visible result only when it is a
string. If it raises or returns another type, Mithai logs the problem and preserves the original
successful result. Failed MCP results, native tools, MCP aliases declared through MCP_TOOLS,
and servers without an owning skill are never observed.
This hook is post-read only. It cannot authorize, suppress, retry, or rewrite the MCP call.
Calls can run concurrently across tools, turns, and channels. If the observer keeps mutable
module-level state, it must synchronize that state itself (for example, with a lock) and key any
per-session state by ctx["channel_id"]. Mithai does not serialize observer calls.
Each observer call is bounded at 30s. On timeout, Mithai logs a warning and returns the
original (unaugmented) MCP result; the abandoned observer thread does not block the turn.
Because a turn runs at most 4 tool calls at a time, n slow/hung observers can add up to
30s × ceil(n / 4) to a single turn — relevant for synchronous POST /api/trigger?wait=true
callers (see deployment.md).
tool_result_empty(tool_name, raw_result) (optional)
Section titled “tool_result_empty(tool_name, raw_result) (optional)”Tells API stream consumers whether a successful result of one of this skill’s tools (or of an
MCP server it owns via MCP_SERVERS) is empty. Its bool answer becomes is_empty on
tool_completed (see configuration.md); None, a non-bool or a raised
exception falls back to a bool ctx["mcp_execution_metadata"]["is_empty"] set by the handler,
then to generic emptiness ("", [], {}, null).
def tool_result_empty(tool_name: str, raw_result: str) -> bool | None: if tool_name == "get_logs": return '"result": []' in raw_result return Nonetool_name is unprefixed, as in resolve_human; raw_result is the router’s result before any
adapter rewrite or size compaction.
tool_target(tool_name, tool_input) (optional)
Section titled “tool_target(tool_name, tool_input) (optional)”Tells API stream consumers which entity, and what kind of data, a call to one of this skill’s
tools (or of an MCP server it owns) queries. tool_started includes a redacted, size-capped
presentation copy of inputs, preserving query fields. This hook supplies explicit target
metadata; mithai never derives that metadata from raw input.
def tool_target(tool_name: str, tool_input: dict) -> dict | None: if tool_name == "get_logs": return {"target": tool_input.get("service"), "query_kind": "logs"} return NoneReturn a dict with target (str, 1–128 chars) and/or query_kind (1–32 chars of [a-z_]). They
are added to tool_started as-is; any other key, and any value of the wrong type or length, is
dropped rather than truncated. Returning None or raising (logged) emits no target and never fails
the tool call. The hook sees the input that will execute, after any adapter before_tool_call
rewrite, and gets a copy of it, so mutating it has no effect.
MCP_TOOLS (optional)
Section titled “MCP_TOOLS (optional)”Declares which external MCP (Model Context Protocol) servers this skill uses. The framework starts only the MCP servers that are actually needed.
MCP_TOOLS = [ { "server": "linear", "tools": ["create_issue", "list_issues", "update_issue"], },]MCP servers are declared in config.yaml under mcp_servers. Tools from MCP servers are namespaced the same way as native tools.
VERIFY (optional)
Section titled “VERIFY (optional)”Set to True to enable post-turn fact-checking for this skill. After each turn where a verified skill was called, a secondary LLM call checks the agent’s response against what the tools actually returned, and annotates the response with ⚠️ if a numerical or factual contradiction is found.
VERIFY = TrueUse this for skills that query external systems with precise values (counts, sizes, versions, costs) where hallucinated summaries would be harmful — for example infrastructure skills like aws or kubernetes. No skill enables it by default.
To use a cheaper model for the fact-check, configure it in config.yaml:
verifier: model: claude-haiku-4-5The ctx object
Section titled “The ctx object”Every call to handle and resolve_human receives a ctx dict with the following keys:
ctx["config"]
Section titled “ctx["config"]”The skill’s configuration block from config.yaml. Use .get() with defaults — the block may be empty if the user hasn’t configured the skill.
skills: config: my_skill: api_url: https://api.internal timeout: 30def handle(name, input, ctx): config = ctx.get("config", {}) url = config.get("api_url", "https://api.example.com") timeout = config.get("timeout", 10)ctx["memory"]
Section titled “ctx["memory"]”The memory backend. Persists across restarts and sessions.
memory = ctx.get("memory")if memory: # Read a file content = memory.read("playbooks/restart.md")
# Write or append to a file memory.write("incidents.md", "- pod OOMKilled at 14:23\n", append=True)
# Read a JSON file as a dict approvals = memory.read_json("approvals.json") or {}Files are stored in memory/ (configurable). Paths are relative to that root.
ctx["state"]
Section titled “ctx["state"]”Key-value store for ephemeral state within a session. Also persisted to disk.
state = ctx.get("state")if state: state.set("last_checked", "2026-03-16T14:23:00Z") value = state.get("last_checked")ctx["channel_id"]
Section titled “ctx["channel_id"]”String. The channel or chat the message came from. In Slack: the channel ID. In Telegram: the chat ID. In the CLI: "cli".
ctx["user_id"]
Section titled “ctx["user_id"]”String. The user who sent the message. In Slack: the user’s Slack ID. In Telegram: the user’s Telegram ID. May be None if not available.
ctx["logger"]
Section titled “ctx["logger"]”Structured logger.
logger = ctx.get("logger")if logger: logger.info("checking health", url=url) logger.warning("slow response", url=url, ms=elapsed_ms) logger.error("request failed", url=url, error=str(e))ctx["tool_route_context"]
Section titled “ctx["tool_route_context"]”get_tool_route_context(ctx) from mithai.core.context returns the frozen
ToolRouteContext, or None when no routing context was attached.
Its optional incoming_message field references the current turn’s IncomingMessage
in both the normal engine loop and guarded turn handlers. user_message remains
the text-only field for existing consumers.
The reference cannot be reassigned, but the message is live, not a deep-frozen
snapshot. A classifier skill may set a host-owned attribute such as
route_ctx.incoming_message.route = "search"; subsequent tool handlers and
before_mcp_call hooks in that turn see it on the same object. Hooks should treat
the message as read-only. Direct router callers that do not supply a message
get incoming_message=None.
The engine does not copy these attributes into session state or subsequent
messages. Hosts must supply a fresh message for each turn and must not retain
the reference as cross-turn state. Concurrent sessions use their own messages.
Tools within a turn can run concurrently: complete classification before
dispatching dependent calls, and synchronize any concurrent attribute writes.
Do not serialize or log the message wholesale; it can contain delegated credentials.
The same applies to the routing context: incoming_message is excluded from its
repr, but dataclasses.asdict(route_ctx) still includes it. Repr exclusion is
not redaction for serialization. Select only fields known to be safe when
building logs or serialized payloads.
Built-in skills
Section titled “Built-in skills”These skills ship with mithai and are available immediately after mithai init.
Run shell commands on the host. Uses dynamic approval — commands on the allowlist auto-execute, new commands require approval, and commands accumulate approvals until they auto-promote.
Tools: run_command(command)
Approval: Dynamic. Allowlisted commands: auto. New commands: approve. Auto-promoted commands: auto.
Config:
skills: config: shell: allowed_commands: - "df -h" - "free -h" - "uptime" approval_auto_promote: 3 # number of approvals before auto-exec timeout: 30 # seconds before command is killedShell operators (|, &&, >, ;) are detected automatically and run with shell=True. All commands run with stdin=DEVNULL to prevent interactive hangs.
memory
Section titled “memory”Read and write persistent files. The agent uses this to record facts, save playbooks, and look up past decisions.
Tools: memory_read(path), memory_write(path, content, mode), memory_search(query)
Approval: Auto for all operations.
memory/MEMORY.md is automatically injected into every conversation. Write facts there that the agent should always have available.
http_checker
Section titled “http_checker”Check whether HTTP endpoints are reachable.
Tools: check_url(url)
Approval: Auto.
Returns: { url, status, response_time_ms, healthy }
Configuration overrides
Section titled “Configuration overrides”Any tool’s approval level can be overridden in config.yaml without modifying the skill:
human: timeout_seconds: 300 # how long to wait for approval before timing out overrides: shell__run_command: confirm # escalate: require typing kubernetes__get_pods: null # de-escalate: auto-execute services__restart_service: null # trust this operation completelyKey format: skillname__toolname.
Loading skills
Section titled “Loading skills”By default, mithai loads skills from ./skills. Add additional paths in config.yaml:
skills: paths: - ./skills - ./custom_skills - /opt/shared/mithai-skillsSkills are loaded in order. If two skills define a tool with the same prefixed name, the last one wins.
mithai skill list # list loaded skills (and their tools)mithai skill validate # validate all skills without starting the engine