Your First Agent
Let's get a real AI chatbot running with AgentVisor™. You'll have a working conversational agent in under five minutes.
What You'll Learn
- Creating a project from a template and running it with
agentvisor serve - Interacting with an agent via the HTTP API
- How agent code, policies, and services fit together
Prerequisites
- Docker installed and running
- AgentVisor CLI installed (see Getting Started)
This tutorial creates a LangGraph agent. LangGraph is the recommended framework for stateful agents—see Supported Frameworks for all built-in providers.
Create the Project
agentvisor template create langgraph/chatbot-agent
cd chatbot-agent
This creates a ready-to-run project:
chatbot-agent/
├── agent.py # LangGraph agent code
├── langgraph.json # Graph configuration
├── requirements.txt # Python dependencies
├── compose.yml # Development services (Temporal + Ollama)
├── agentvisor.yaml # Runtime config (SSRF allowlist for the loopback Ollama endpoint)
├── mav-agent-config.yaml # Agent-level config (A2A skill metadata)
├── policies/
│ ├── domain.yml # Access control policies
│ └── test.yml # Policy test cases
├── .dockerignore # Files excluded from container builds
└── README.md # Template documentation
See Project Structure for details on what each file does.
Start Services and Run
# Start Temporal (workflow orchestration) and Ollama (local LLM)
docker compose up -d
# Run the agent
agentvisor serve .
The agentvisor serve command:
- Downloads a guest container image (first run only)
- Mounts your agent code into a sandbox
- Installs Python dependencies
- Starts the HTTP API on port 8090
agentvisor serve runs in Docker sandbox mode — production-grade containment that works on any platform (Linux, macOS, Windows). agentvisor build produces images using gVisor by default, adding syscall-level interception for slightly stronger defense-in-depth on Linux hosts. See Sandbox Modes and Deploying Your Agent for the full picture.
Once running, open http://localhost:8090/swagger-ui/ in your browser to explore the API interactively.
Test It
In a separate terminal:
# Create a conversation thread
THREAD=$(curl -sX POST http://localhost:8090/threads | jq -r '.thread_id')
echo "Thread: $THREAD"
# Send a message
curl -sX POST "http://localhost:8090/threads/$THREAD/runs?wait=60s" \
-H "Content-Type: application/json" \
-d '{"input": {"messages": [{"role": "user", "content": "Hello! What can you help me with?"}]}}' \
| jq '.output.messages[-1].content'
You should see a conversational response from the chatbot.
Try a follow-up message — the agent remembers context from previous messages in the same thread:
curl -sX POST "http://localhost:8090/threads/$THREAD/runs?wait=60s" \
-H "Content-Type: application/json" \
-d '{"input": {"messages": [{"role": "user", "content": "Tell me more about that."}]}}' \
| jq '.output.messages[-1].content'
Key endpoints:
GET /agents— List available agentsPOST /threads— Create conversation threadPOST /threads/{id}/runs?wait=30s— Execute a runGET /threads/{id}/state— Get thread state
Understanding the Code
agent.py
The agent is a LangGraph state graph that calls a local LLM via Ollama.
The code snippets below are simplified versions focused on key concepts. The actual agent.py source includes additional error handling, message type conversion, and uses ChatState rather than AgentState. See your project's agent.py (scaffolded above) for production patterns.
class AgentState(TypedDict):
"""Conversation state — messages auto-accumulate via add_messages."""
messages: Annotated[list, add_messages]
The add_messages annotation is a LangGraph feature that automatically appends new messages to the list rather than replacing it. This is how conversation history is maintained across runs.
def chat(state: AgentState) -> dict:
"""Call the LLM with the full conversation history."""
llm = ChatOllama(model=model, base_url=ollama_url)
response = llm.invoke(state["messages"])
return {"messages": [response]}
The chat function sends the full conversation history to Ollama and returns the response. Because it uses OLLAMA_BASE_URL (defaulting to http://localhost:11434), the HTTP request goes through AgentVisor's policy-enforced proxy — no direct network access. The template's agentvisor.yaml allowlists this loopback destination for the proxy's SSRF guard, which otherwise blocks loopback by default.
graph = builder.compile(checkpointer=AgentVisorCheckpointer())
AgentVisorCheckpointer implements LangGraph's checkpointer interface but persists state to the Temporal workflow, enabling state survival across restarts and multi-turn conversations.
langgraph.json
Declares available graphs and their entry points:
{
"dependencies": ["."],
"graphs": {
"chatbot": "./agent.py:graph"
}
}
| Field | Description |
|---|---|
dependencies | Python path(s) to add (usually ["."] for current directory) |
graphs | Map of graph names to file:variable paths |
policies/domain.yml
The chatbot template includes a real, four-phase policy — not a grant-all bypass:
- Operation phase: Validates that requests are authenticated
- Resource phase: Allows access to AgentVisor API resources (threads, runs, state, agents, store)
- HTTP phase: Only allows requests to Ollama endpoints (
ollama,localhost:11434,127.0.0.1:11434) - Default: Denies everything not explicitly allowed
This means your agent can talk to Ollama but cannot reach any other endpoint. The chatbot itself has no HTTP tool to point elsewhere, so to see the policy actually deny something, tighten the allowlist and watch the agent's own Ollama call get blocked instead.
Edit policies/domain.yml and remove the localhost:11434 selector line — this template's OLLAMA_BASE_URL defaults to http://localhost:11434, so this is the line actually matching your traffic:
# Ollama HTTP endpoints (selector-based since host doesn't pre-specify group)
- name: ollama-endpoints
description: "Ollama LLM API endpoints"
selector:
- "mrn:agentvisor:http:ollama.*"
# - "mrn:agentvisor:http:localhost:11434.*" # removed to test policy denial
- "mrn:agentvisor:http:127\\.0\\.0\\.1:11434.*"
group: "mrn:agentvisor:resourcegroup:ollama"
Restart the agent (Ctrl+C, then agentvisor serve . again) and send another message:
curl -sX POST "http://localhost:8090/threads/$THREAD/runs?wait=30s" \
-H "Content-Type: application/json" \
-d '{"input": {"messages": [{"role": "user", "content": "Hello again"}]}}' \
| jq '.output.messages[-1].content'
The proxy now denies the LLM call before it leaves the host (the proxy responds 403 Forbidden). agent.py's chat() node catches the resulting error and returns it as the assistant's message, so you'll see an error string instead of a normal chat reply — the policy engine, not the agent code, blocked the request. Restore the selector line afterward so the agent works normally again.
See Policy Configuration for details on writing policies.
compose.yml
The compose file starts two development services:
- Temporal: Workflow orchestration — manages thread lifecycle, checkpointing, and durable execution
- Ollama: Local LLM inference — pulls the
llama3.2:1bmodel automatically on first start
The Temporal Web UI is available at http://localhost:8233 for inspecting workflows.
Next Steps
- Configuring Your Agent: Environment variables with .env files
- Project Structure: Deep dive into each file
- QA Agent: Tool calling with the ReAct pattern