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
- 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.
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.
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.
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.
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.
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 redacts query/sql/script inputs,
so this hook is the only source of that information; mithai never derives it 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