PluginContext API
ctx object passed to your register(ctx) function is the only public surface plugins use. Four registration methods cover every plugin capability.register_tool
ctx.register_tool(*, name, schema, handler, check_fn=None, description='')Register a new tool the agent can call. Handler can be sync or async; Flowly handles both. The schema follows JSON Schema semantics — typically an object with properties and required fields.
| Parameter | Type | Description |
|---|---|---|
| name | str | Tool name as the agent sees it. Must be unique across all loaded tools. |
| schema | dict | JSON Schema for tool parameters. May be the parameters dict directly or the full OpenAI function schema with parameters inside. |
| handler | Callable | Sync or async function. Receives tool arguments as keyword args, returns a string (or any value coerced to string). |
| check_fn | Callable | None | Optional zero-arg callable returning bool. Runs at dispatch time, not register time. When False, the tool returns "unavailable" without calling handler. Useful for OAuth-gated tools. |
| description | str | Human-readable description shown to the agent. Falls back to schema.description if empty. |
def register(ctx):
async def lookup(query: str, limit: int = 10) -> str:
results = await my_api.search(query, limit=limit)
return "\n".join(r.title for r in results)
ctx.register_tool(
name="my_search",
schema={
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "default": 10},
},
"required": ["query"],
},
},
handler=lookup,
check_fn=lambda: bool(os.getenv("MY_API_KEY")),
description="Search my service",
)register_hook
ctx.register_hook(event_name, callback)Subscribe to a lifecycle event. The callback receives an event-specific context dataclass (e.g. ToolHookContext, SessionHookContext). It can be sync or async.
The callback may return an action object to influence runtime flow:
BlockAction(message=...)frompre_tool_callaborts dispatch with that messageSkipAction(reason=...)frompre_gateway_dispatchdrops the inbound message silentlyRewriteAction(text=...)frompre_gateway_dispatchreplaces the message text- A bare
strfromtransform_tool_result/transform_terminal_outputreplaces the original output
Common patterns:
Block dangerous tool calls — return BlockAction from pre_tool_call:
from flowly.agent.hooks import BlockAction
def register(ctx):
def reject_root_writes(hook_ctx):
if hook_ctx.tool_name == "write_file":
path = hook_ctx.params.get("path", "")
if path.startswith("/etc/"):
return BlockAction("writes to /etc/ are not allowed")
ctx.register_hook("pre_tool_call", reject_root_writes)Inject domain context before LLM calls — return a string from pre_llm_call. The string is wrapped in <plugin_context> tags and prepended to the last user message. Useful when your plugin's tools won't be picked up by the model unless you explicitly orient it toward your domain:
def register(ctx):
def add_legal_context(ctx):
return (
"User is a lawyer with active cases. When you see a client "
"name or 'dava' / 'müvekkil', prefer case_search over "
"memory_search. Available: case_search, case_get, case_list, "
"case_create, case_update, case_add_event."
)
ctx.register_hook("pre_llm_call", add_legal_context)Audit trail of every tool call — observe-only with post_tool_call:
def register(ctx):
def log_tool(ctx):
_log({"tool": ctx.tool_name, "duration_ms": ctx.duration_ms,
"success": ctx.success})
ctx.register_hook("post_tool_call", log_tool)register_command
ctx.register_command(name, handler, description='', args_hint='')Register an in-session slash command. Available across all channels — Telegram, Web, Desktop, iOS — wherever the user can type into a chat.
| Parameter | Type | Description |
|---|---|---|
| name | str | Command name without the leading slash. Lowercased and hyphenated automatically (/Foo Bar → /foo-bar). |
| handler | Callable[[str], str | None] | Sync or async. Receives raw_args (everything after the command name). Returns a string to send back, or None for fire-and-forget. |
| description | str | Shown in /help and the desktop UI command list. |
| args_hint | str | Optional hint like "<city>" or "id:42 mode:quick" — surfaced by gateways with native command pickers (Discord). |
def register(ctx):
def ping(args: str) -> str:
return "pong" + (f" {args}" if args else "")
ctx.register_command(
"ping",
handler=ping,
description="Round-trip latency check",
args_hint="[message]",
)/new, /clear, /compact, /help) are rejected with a warning — they belong to Flowly core.register_skill
ctx.register_skill(name, path, description='')Register a markdown skill the agent can load explicitly via skill_view("<plugin>:<name>"). Plugin skills don't appear in the system prompt's available-skills index — they are opt-in only — which keeps the prompt cache prefix stable across plugin sets.
| Parameter | Type | Description |
|---|---|---|
| name | str | Bare name (no colons). Becomes "<plugin_name>:<name>" when loaded. |
| path | Path | Path to a SKILL.md file. Must exist when register() is called. |
| description | str | Description shown in flowly skills list. |
from pathlib import Path
def register(ctx):
ctx.register_skill(
name="onboard",
path=Path(__file__).parent / "skills" / "onboard" / "SKILL.md",
description="First-run user onboarding flow",
)
# The agent loads this with:
# skill_view(name="my-plugin:onboard")