Audience: developers building plugins, tools, or surface adapters for
mas-runtime. Covers the full authoring lifecycle from zero-ceremony@plugindecorator to governance hooks and observability wiring.
- Quick Start: Your First Plugin in 10 Lines
- The @plugin Decorator
- Tool Authoring
- Built-in Tool Library
- Contract Taxonomy Cheat Sheet
- Surface adapters
- Observability: What Gets Emitted
- Governance Integration
- Packaging & Installation
from mas.runtime.contracts import plugin, ToolContract
@plugin
class GreeterTool(ToolContract):
def get_name(self):
return "greet"
def get_description(self):
return "Greet someone by name."
def get_parameters_schema(self):
return {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Name to greet."}
},
"required": ["name"],
}
def execute(self, **kwargs):
return {"message": f"Hello, {kwargs['name']}!"}That's it. No plugin_id, no implements, no ClassVar declarations.
The @plugin decorator infers everything:
>>> GreeterTool.plugin_id
'greeter@v1'
>>> GreeterTool.implements
['tool']| Field | Auto-inferred from | Override syntax |
|---|---|---|
plugin_id |
Class name → snake_case@v1 (strips Tool/Plugin suffix) |
@plugin(plugin_id="my_id@v2") or set plugin_id = "..." as ClassVar |
implements |
MRO walk — collects contract_id from all parent contracts |
Set implements = [...] as ClassVar |
requires |
(not inferred) | @plugin(requires=["recorder"]) |
governed_by |
(not inferred) | @plugin(governed_by=["budget"]) |
| Class Name | Auto plugin_id |
|---|---|
DateTimeTool |
date_time@v1 |
MyHTTPClientPlugin |
my_http_client@v1 |
BudgetGovernance |
budget_governance@v1 |
SimplePlugin |
simple@v1 |
# Form 1: bare decorator (most common)
@plugin
class MyTool(ToolContract): ...
# Form 2: with explicit overrides
@plugin(requires=["recorder"], governed_by=["budget"])
class MyTool(ToolContract): ...
# Form 3: mixed — decorator + ClassVar override
@plugin
class MyTool(ToolContract):
plugin_id = "my_custom_id@v3" # this takes precedence
implements = ["tool", "custom"] # this takes precedenceWhen a plugin inherits from multiple contracts, @plugin discovers all of them:
@plugin
class SmartMemoryTool(ToolContract, MemoryContract):
...
>>> SmartMemoryTool.implements
['tool', 'memory']from mas.runtime.contracts import plugin
# or
from mas.runtime.contracts.base import pluginAll tools inherit from ToolContract. Two styles are supported.
from pydantic import BaseModel, Field
from mas.runtime.contracts import plugin, ToolContract
@plugin
class WeatherTool(ToolContract):
class Input(BaseModel):
city: str = Field(..., description="City name")
units: str = Field("metric", description="metric or imperial")
def get_name(self):
return "weather"
def get_description(self):
return "Get current weather for a city."
# get_parameters_schema() is auto-derived from Input.model_json_schema()
# No need to override it!
def execute(self, **kwargs):
args = self.Input(**kwargs) # Pydantic validation
return {"city": args.city, "temp": 22, "units": args.units}@plugin
class CalculatorTool(ToolContract):
def get_name(self):
return "calculator"
def get_description(self):
return "Evaluate a math expression."
def get_parameters_schema(self):
return {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "Math expression to evaluate (e.g. '2 + 3 * 4')"
}
},
"required": ["expression"],
}
def execute(self, **kwargs):
# ... safe evaluation logic ...
return {"result": 42}collect_tools hook
└─ ToolContract.on_collect_tools()
└─ returns [{name, description, parameters}]
execute_tool hook
└─ ToolContract.on_execute_tool(tool_name, arguments)
└─ ToolContract.call_tool(name, args)
└─ ToolContract.execute(**args)
Tutorial and benchmark tools live in library-samples/tools/ as kind: Tool
manifests. Agents reference them via spec.tools[].ref (e.g.
samples:tools/memory-search.tool.yaml). The runtime loads tool classes from
those manifests — there is no built-in tool package in mas.runtime.
from mas.runtime.tools import DateTimeTool
tool = DateTimeTool()
tool.execute()
# → {"datetime": "2026-04-11T13:00:31.566334+00:00", "format": "iso"}
tool.execute(format="unix")
# → {"timestamp": 1744376431.5, "format": "unix"}
tool.execute(format="%Y-%m-%d", timezone="+05:30")
# → {"datetime": "2026-04-11", "format": "%Y-%m-%d"}| Parameter | Type | Default | Description |
|---|---|---|---|
format |
string | "iso" |
"iso", "unix", or any strftime pattern |
timezone |
string | "utc" |
"utc" or UTC offset like "+05:30" |
from mas.runtime.tools import JsonTool
tool = JsonTool()
# Parse
tool.execute(operation="parse", data='{"a": 1}')
# → {"status": "ok", "result": {"a": 1}}
# Dot-path extraction
tool.execute(operation="get", data='{"user": {"name": "Alice"}}', path="user.name")
# → {"status": "ok", "result": "Alice"}
# Pretty-print
tool.execute(operation="format", data='{"a":1,"b":2}')
# → {"status": "ok", "result": "{\n \"a\": 1,\n \"b\": 2\n}"}
# Merge objects
tool.execute(operation="merge", data='{"a": 1}', merge_with='{"b": 2}')
# → {"status": "ok", "result": {"a": 1, "b": 2}}| Parameter | Type | Required | Description |
|---|---|---|---|
operation |
string | yes | "parse", "format", "get", "merge" |
data |
string | yes | JSON string to process |
path |
string | for get |
Dot-separated path (e.g. "user.name", "items.0") |
merge_with |
string | for merge |
Second JSON string |
from mas.runtime.tools import TextTool
tool = TextTool()
tool.execute(operation="count", text="Hello world")
# → {"characters": 11, "words": 2, "lines": 1}
tool.execute(operation="search", text="Error: 404, Error: 500", pattern=r"Error: (\d+)")
# → {"status": "ok", "count": 2, "matches": [...]}
tool.execute(operation="replace", text="Hello world", pattern="world", replacement="earth")
# → {"status": "ok", "result": "Hello earth"}
tool.execute(operation="extract", text="Call 123-456 or 789-012", pattern=r"\d{3}-\d{3}")
# → {"status": "ok", "matches": ["123-456", "789-012"]}
tool.execute(operation="truncate", text="a" * 200, max_length=50)
# → {"status": "ok", "result": "aaaa...…", "truncated": true}| Operation | Parameters | Description |
|---|---|---|
count |
text |
Word, character, and line counts |
search |
text, pattern |
Regex search with match positions |
replace |
text, pattern, replacement |
Regex replace |
extract |
text, pattern |
Extract all regex matches |
split |
text, delimiter |
Split by delimiter |
truncate |
text, max_length |
Truncate with ellipsis |
from mas.runtime.tools import HttpTool
tool = HttpTool()
tool.execute(url="https://api.example.com/data", method="GET")
# → {"status": "ok", "status_code": 200, "headers": {...}, "body": {...}}
tool.execute(
url="https://api.example.com/items",
method="POST",
body='{"name": "Widget"}',
headers={"Authorization": "Bearer token123"},
)Security features:
- SSRF protection: blocks
localhost,127.0.0.1,::1,10.x.x.x,172.16-31.x.x,192.168.x.x,169.254.169.254(cloud metadata) - Response size limit: 1 MB max
- Timeout: configurable, max 120 seconds
| Parameter | Type | Required | Default |
|---|---|---|---|
url |
string | yes | — |
method |
string | no | "GET" |
headers |
object | no | {} |
body |
string | no | — |
query_params |
object | no | — |
timeout |
integer | no | 30 |
Filesystem and shell tools with sandbox governance are not part of the OSS
release. They ship in mas-lab-internal with SandboxContract enforcement.
| Tool | Name | Key operations | External deps | Governance |
|---|---|---|---|---|
DateTimeTool |
datetime |
ISO, Unix, strftime, timezone | None | — |
JsonTool |
json |
parse, format, get, merge | None | — |
TextTool |
text |
count, search, replace, extract, split, truncate | None | — |
HttpTool |
http |
GET, POST, PUT, DELETE | None | SSRF built-in |
CalculatorTool |
calculator |
safe math evaluation | None | — |
WebSearchTool |
web_search |
cached web search | None | — |
VerifyFactTool |
verify_fact |
fact verification | None | — |
MemorySearchTool |
memory_search |
semantic memory search | None | — |
MemoryGetTool |
memory_get |
key-value memory retrieval | None | — |
GetScheduleTool |
get_schedule |
schedule lookup | None | — |
GetAttractionsTool |
get_attractions |
attraction lookup | None | — |
ContractBase
├── CapabilityContract — WHAT the agent can access (20 contracts)
│ ├── ToolContract (tool)
│ ├── PromptContract (prompt)
│ ├── MemoryContract (memory)
│ ├── SensorContract (sensor)
│ ├── SessionContract (session)
│ ├── ExecutionSessionContract (execution)
│ ├── TransportContract (transport)
│ ├── MessageContract (message)
│ ├── RecorderContract (recorder)
│ ├── ControlContract (control)
│ ├── SharedContextContract (shared_context)
│ ├── DelegationContract (delegation)
│ ├── ModelAccessContract (model_access)
│ ├── ContextContract (context — BasePlugin mixin)
│ ├── LLMContract (llm)
│ ├── DesignPatternContract (dp)
│ ├── GatewayContract (gateway)
│ └── SurfaceAdapter (surface)
│
├── OrchestrationContract — HOW control flows
│ └── WorkflowContract (workflow)
│
└── GovernanceContract — POLICY over capabilities
├── BudgetContract (budget)
└── RoutingContract (routing)
# SandboxContract, TBACContract — mas-lab-internal only
Evolution (L8) is not in OSS — internal extension only.
Connects external delivery surfaces (Slack, web, CLI, agent-remote) to the runtime through the hook system.
from mas.runtime.contracts import plugin, SurfaceAdapter, SurfaceEnvelope
@plugin
class SlackAdapter(SurfaceAdapter):
@property
def surface_type(self):
return "slack"
def ingest(self, raw_event):
"""Convert a Slack event → SurfaceEnvelope."""
return SurfaceEnvelope(
surface_type="slack",
channel_id=raw_event["channel"],
sender_id=raw_event["user"],
thread_id=raw_event.get("thread_ts", ""),
content=raw_event["text"],
)
def deliver(self, envelope, response):
"""Send agent response back to Slack."""
# slack_client.chat_postMessage(
# channel=envelope.channel_id,
# text=response.get("content", ""),
# thread_ts=envelope.thread_id,
# )
pass
def start(self):
"""Start the Slack event listener."""
pass
def stop(self):
"""Stop the listener."""
passSurfaceEnvelope fields:
| Field | Type | Description |
|---|---|---|
surface_type |
str | "slack", "discord", "web", "cli", "agent-remote" |
channel_id |
str | Surface-native channel ID |
sender_id |
str | Surface-native sender ID |
thread_id |
str | Thread/conversation ID |
content |
str | Message text content |
attachments |
List[Dict] | File attachments |
metadata |
Dict | Arbitrary surface metadata |
Every ContextPart now has a unique part_id (UUID v4), auto-generated
at construction time:
from mas.runtime.contracts import ContextPart
part = ContextPart(content="Remember this fact", source="memory")
print(part.part_id)
# → "fba8fb4a-9bf8-41c4-a9ab-ed85dac0fab8"
# In the observability dict:
obs = part.to_observability_dict()
assert obs["part_id"] == part.part_idThe context assembler emits a context_part_contributed event per
part (retained and evicted) to the agent's recorder. This enables
token-level attribution — you can trace exactly why each token is in the
prompt.
Retained part event:
{
"kind": "context_part_contributed",
"agent_id": "sre-agent",
"timestamp": 1744376431.5,
"part_id": "fba8fb4a-...",
"source": "memory:episodic",
"section_id": "memory/episode-42",
"mechanism": "memory_read",
"cause": "dp_intent",
"cause_type": "memory",
"token_estimate": 150,
"retained": true
}Evicted part event:
{
"kind": "context_part_contributed",
"agent_id": "sre-agent",
"timestamp": 1744376431.5,
"source": "skills",
"section_id": "skills/network-analysis",
"token_estimate": 400,
"retained": false,
"eviction_reason": "budget_exceeded"
}Emitted after all blocking hooks on a hook point complete without raising. Previously, success was invisible. Now it's auditable.
{
"kind": "governance_checked",
"hook": "pre_llm_call",
"checks_passed": 3,
"outcome": "allowed"
}When emitted: after the hook loop for any blocking hook point completes without exception. Not emitted for non-blocking hooks or when no hooks are registered.
Emitted when a PolicyViolation is raised by a governance plugin.
Distinguishes governance denial from technical errors.
{
"kind": "governance_denied",
"hook": "pre_llm_call",
"plugin": "BudgetPlugin",
"contract_id": "budget",
"reason": "Token ceiling exceeded",
"details": {"remaining": 0, "requested": 500}
}Contrast with hook_error (technical failure):
{
"kind": "hook_error",
"hook": "pre_llm_call",
"plugin": "LLMAdapter",
"error": "Connection refused"
}Decision logic in _emit_hook_error:
Exception caught in hook dispatch
├─ Is it a PolicyViolation? → emit governance_denied
└─ Otherwise? → emit hook_error
Agent requests LLM call
↓
pre_llm_call hook fires (BLOCKING)
↓
Plugin 1: BudgetPlugin.on_pre_llm_call() → checks token ceiling
├─ OK → continue to next plugin
└─ DENY → raise PolicyViolation → governance_denied event → STOP
...
All plugins passed → governance_checked event emitted
↓
LLM call proceeds
@plugin(governed_by=["budget"])
class RateLimitedTool(ToolContract):
...This declaration is informational metadata — governance contracts hook into the same hook points and run automatically.
from mas.runtime.contracts.base import GovernanceContract, PolicyViolation
class RateLimitGovernance(GovernanceContract):
contract_id = "rate_limit"
def __init__(self, max_calls_per_minute=60):
super().__init__()
self._max = max_calls_per_minute
self._calls = []
def on_pre_llm_call(self, data=None, **kw):
import time
now = time.time()
self._calls = [t for t in self._calls if now - t < 60]
if len(self._calls) >= self._max:
raise PolicyViolation(
contract_id="rate_limit",
reason=f"Rate limit exceeded: {self._max}/min",
details={"current_count": len(self._calls)},
)
self._calls.append(now)# Core only (minimal)
uv pip install -e mas-runtime
# OpenTelemetry export (internal / mas-lab-internal only — not an OSS extra)
# uv pip install -e "mas-runtime[otel]" # see mas-lab-internal
# With RAG (llama-index)
uv pip install -e "mas-runtime[rag]"
# With gRPC transport
uv pip install -e "mas-runtime[grpc]"
# Everything
uv pip install -e "mas-runtime[all]"| Extra | Packages included |
|---|---|
otel |
opentelemetry-api, opentelemetry-sdk, opentelemetry-exporter-otlp |
rag |
llama-index-core, llama-index-readers-file |
grpc |
grpcio, grpcio-tools |
all |
All of the above |
test |
pytest, pytest-asyncio, hypothesis, jinja2 |
uv pip install -e mas-runtime -e mas-lab| Hook | Blocking | Modification | Category |
|---|---|---|---|
pre_execution |
Yes | Yes | Execution |
post_execution |
No | No | Execution |
pre_llm_call |
Yes | Yes | LLM |
post_llm_call |
No | Yes | LLM |
pre_tool_call |
Yes | Yes | Tool |
post_tool_call |
No | Yes | Tool |
collect_tools |
No | No | Tool |
execute_tool |
No | No | Tool |
pre_prompt_build |
Yes | Yes | Prompt |
post_prompt_build |
No | Yes | Prompt |
pre_memory_store |
Yes | Yes | Memory |
post_memory_store |
No | No | Memory |
pre_agent_communication |
Yes | Yes | Communication |
post_agent_communication |
No | No | Communication |
pre_sensor_event |
Yes | Yes | Sensor |
post_sensor_event |
No | No | Sensor |
pre_session_access |
Yes | Yes | Session |
post_session_access |
No | No | Session |
on_pre_checkpoint |
Yes | No | Execution |
on_post_checkpoint |
No | No | Execution |
on_pre_restore |
Yes | No | Execution |
on_post_restore |
No | No | Execution |
pre_agent_handoff |
Yes | Yes | Communication |
post_agent_handoff |
No | No | Communication |
pre_context_assembly |
No | Yes | Context |
post_context_assembly |
No | No | Context |
collect_context |
No | No | Context |
on_error |
No | No | Error |
user_input |
Yes | Yes | I/O |
user_output |
Yes | Yes | I/O |
object_model_event |
No | No | KG |
skills_check |
No | No | Skills |
| Test file | Tests | What it covers |
|---|---|---|
test_plugin_decorator.py |
13 | @plugin auto-inference, overrides, ClassVar preservation, multi-contract |
test_tool_libraries.py |
41 | All 6 new tools: DateTime, Json, Text, FileOps, Shell, Http |
test_governance_visibility.py |
8 | governance_checked, governance_denied, denial chain behavior |
test_l4_part_id_and_provenance.py |
17 | part_id generation, context_part_contributed events, eviction |
Total: 1760 tests passing, 0 failures, 0 skips.