How to define, organize, and expose tools to AI agents in Visor — from simple API calls to complex multi-step workflows.
| Concept | What it does | One definition gives you |
|---|---|---|
Custom tool (type: command) |
Shell command with templates | 1 tool |
API tool bundle (type: api) |
OpenAPI spec → auto-generated tools | N tools (one per operationId) |
Workflow tool (type: workflow) |
Multi-step tool with custom logic | 1 tool |
| Toolkit file | A file with multiple tool definitions | N tools (loaded via toolkit:) |
| Skill | Groups tools + knowledge for AI agent routing | Activates tools when intent matches |
A shell command exposed as a tool:
tools:
git-status:
name: git-status
description: Get current git status
exec: "git status --porcelain"
parseJson: false
steps:
check:
type: ai
ai_custom_tools: [git-status]
prompt: "Check the git status and summarize changes"Define an API with an OpenAPI spec. Each operationId becomes a separately callable tool.
tools:
my-api:
type: api
headers:
Authorization: "Bearer ${API_TOKEN}"
spec:
openapi: 3.0.0
servers: [{ url: "https://api.example.com" }]
paths:
/users:
get:
operationId: list_users
parameters:
- name: limit
in: query
schema: { type: integer }
responses: { "200": { description: OK } }
/users/{id}:
get:
operationId: get_user
parameters:
- name: id
in: path
required: true
schema: { type: string }
responses: { "200": { description: OK } }This generates two tools: list_users and get_user. Use them in workflow steps:
steps:
fetch:
type: mcp
transport: custom
method: get_user
methodArgs:
id: "{{ inputs.user_id }}"Or expose to AI:
steps:
chat:
type: ai
ai_custom_tools: [my-api] # AI sees list_users and get_userWhen multiple workflows use the same API, extract tools into a shared file:
# tools/api.yaml — shared tools, no steps
tools:
my-api:
type: api
headers:
Authorization: "Bearer ${API_TOKEN}"
spec:
openapi: 3.0.0
servers: [{ url: "https://api.example.com" }]
paths:
/users: ...
/users/{id}: ...
/posts: ...Each workflow extends it and adds only its custom logic:
# workflows/get-user.yaml
extends: ../tools/api.yaml
id: get-user
inputs:
- name: user_id
required: true
steps:
fetch:
type: mcp
transport: custom
method: get_user
methodArgs:
id: "{{ inputs.user_id }}"# workflows/list-posts.yaml
extends: ../tools/api.yaml
id: list-posts
steps:
fetch:
type: mcp
transport: custom
method: list_postsBenefits:
- Define API endpoints once
- Each workflow only contains its custom logic
extendspath is relative to the workflow file- Tools, steps, env, and other config are deep-merged
Define a multi-step tool directly in the tools: section. The workflow is inlined — no separate file needed.
tools:
# Raw API tool
my-api:
type: api
spec: ...
# Multi-step composite tool
smart-lookup:
type: workflow
name: smart-lookup
description: Look up a user by email or ID
inputs:
- name: identifier
required: true
schema: { type: string }
steps:
resolve:
type: script
content: |
const id = inputs.identifier;
if (id.includes('@')) {
return { type: 'email', value: id };
}
return { type: 'id', value: id };
fetch:
type: mcp
transport: custom
method: get_user
depends_on: [resolve]
methodArgs:
id: "{{ outputs['resolve'].value }}"When to use inline vs file:
- Inline: Small tools (2-3 steps), tightly coupled to the parent config
- File: Complex tools needing their own tests, reused across configs
Reference an existing workflow file or registered workflow ID as a tool:
tools:
send-notification:
type: workflow
workflow: workflows/notify.yaml # file path
run-analysis:
type: workflow
workflow: data-analysis # registry ID (must be imported)Load all tools from a file with a single reference:
# In a skill or config
tools:
all-slack:
toolkit: tools/slack-toolkit.yamlWhere the toolkit file contains multiple tool definitions:
# tools/slack-toolkit.yaml
tools:
slack-api:
type: api
spec: ... # generates 5 MCP tools
send-dm:
type: workflow
workflow: send-dm # registered workflow
read-thread:
type: workflow # inline workflow
steps: ...One toolkit: reference → all tools from that file are expanded into the parent.
With overrides — apply properties to every expanded tool:
all-slack:
toolkit: tools/slack-toolkit.yaml
blockedMethods: ["files_delete"] # applied to each toolReference tools by name on an AI step:
steps:
chat:
type: ai
ai_custom_tools: [git-status, my-api, smart-lookup]Compute tools at runtime based on previous step outputs:
steps:
chat:
type: ai
ai_custom_tools_js: |
const tools = ['git-status'];
if (outputs['route'].needs_api) {
tools.push({ workflow: 'data-analysis', args: { mode: 'fast' } });
}
return tools;Skills bundle tools + knowledge and activate based on user intent:
# skills.yaml
- id: devops
description: user wants to check CI, deploy, or manage infrastructure
tools:
# Object format (explicit)
ci-status:
workflow: ci-status
deploy:
workflow: deploy
inputs: { env: staging }
knowledge: |
## DevOps Tools
- ci-status: Check CI pipeline status
- deploy: Deploy to staging/productionArray shorthand — auto-resolves names from the workflow registry:
- id: devops
tools:
- ci-status
- deployToolkit reference — load all tools from a file:
- id: devops
tools:
devops:
toolkit: tools/devops-toolkit.yamlExternal MCP servers (stdio, SSE, HTTP):
- id: jira
tools:
jira:
command: uvx
args: ["mcp-atlassian"]
env:
JIRA_URL: "${JIRA_URL}"| Type | Definition | Execution | Use Case |
|---|---|---|---|
command |
exec: "shell command" |
Shell execution | Git, file ops, scripts |
api |
spec: { openapi spec } |
HTTP request | REST APIs |
workflow (inline) |
steps: { ... } |
Workflow engine | Multi-step with custom logic |
workflow (ref) |
workflow: id-or-path |
Workflow engine | Reusable complex operations |
| MCP server | command: ... or url: ... |
External process | Third-party tools |
workflows/
my-workflow.yaml # tools + steps + tests all in one
workflows/
slack/
api.yaml # shared API definitions
send-dm.yaml # extends api.yaml
search.yaml # extends api.yaml
read-thread.yaml # extends api.yaml
jira/
api.yaml
create-issue.yaml
update-status.yaml
tools/
slack-toolkit.yaml # all Slack tools in one file
jira-toolkit.yaml # all Jira tools in one file
config/
skills.yaml # references toolkits
Workflows with tests are defined inline:
tests:
defaults:
strict: true # all steps must be covered
ai_provider: mock # no real AI/API calls
cases:
- name: happy-path
event: manual
fixture: local.minimal
workflow_input:
user_id: "U123"
mocks:
fetch-user: # mock the MCP API response
ok: true
user: { id: "U123", name: "Alice" }
expect:
calls:
- step: fetch-user
exactly: 1
workflow_output:
- path: result.success
equals: true
- path: result.user.name
equals: "Alice"Run tests:
visor test --config workflows/slack/send-dm.yaml # single file
visor test --config workflows/slack/ # all in directory
visor test # all discoveredConfig (tools: section)
↓
extends / imports ← merge tools from parent configs / register workflows
↓
Skill activation ← route-intent selects skills based on user message
↓
build-config ← merges activated skill tools into mcp_servers
↓
ai_mcp_servers_js ← passes tool configs to AI step
↓
CustomToolsSSEServer ← wraps tools as MCP endpoints (ephemeral HTTP server)
↓
ProbeAgent (AI) ← calls tools via MCP protocol during conversation
| Want to... | Use... |
|---|---|
| Call a shell command as a tool | type: command with exec: |
| Call a REST API | type: api with OpenAPI spec: |
| Share API definitions across workflows | extends: shared-tools.yaml |
| Add custom logic around API calls | Workflow with type: mcp + type: script steps |
| Define inline multi-step tool | type: workflow with steps: in tools section |
| Reference existing workflow as tool | type: workflow with workflow: id-or-path |
| Expose tool to AI step | ai_custom_tools: [tool-name] |
| Expose tools via skills | tools: { my-tool: { workflow: my-workflow } } |
| Load all tools from a file | toolkit: path/to/tools.yaml |
| List tools as simple names | Array syntax: tools: ['tool-a', 'tool-b'] |