Getting Started with DCAF¶
This guide walks you through building AI agents with DCAF (DuploCloud Agent Framework). You'll learn to create agents, define tools, serve them as APIs, and implement human-in-the-loop approval for dangerous operations.
What is DCAF?¶
DCAF is a framework for building AI agents that can:
- Execute tools safely - Dangerous operations require human approval before execution
- Persist state - Session management across conversation turns
- Switch LLM providers - Swap between Bedrock, OpenAI, Anthropic without code changes
- Stream responses - Real-time token-by-token output
from dcaf.core import Agent, serve
from dcaf.tools import tool
@tool(requires_approval=True, description="Delete a Kubernetes pod")
def delete_pod(name: str, namespace: str = "default") -> str:
return kubectl(f"delete pod {name} -n {namespace}")
agent = Agent(tools=[delete_pod])
serve(agent, port=8000)
Prerequisites¶
Required¶
- Python 3.11+ - DCAF supports Python 3.11, 3.12, and 3.13
- AWS Account - With access to AWS Bedrock
- AWS Credentials - With permissions to invoke Bedrock models
Optional¶
- DuploCloud Account - For credential management CLI
- Docker - For containerized deployments
Installation¶
From GitHub¶
For Development¶
git clone https://github.com/duplocloud/service-desk-agents.git
cd service-desk-agents
pip install -r requirements.txt
Verify Installation¶
Environment Setup¶
Option 1: AWS Profiles (Recommended)¶
Use AWS profiles from ~/.aws/credentials:
from dcaf.core import Agent
agent = Agent(
aws_profile="my-profile", # Use this AWS profile
aws_region="us-east-1", # Optional region override
)
Configure profiles in ~/.aws/credentials:
[default]
aws_access_key_id = AKIA...
aws_secret_access_key = ...
[production]
aws_access_key_id = AKIA...
aws_secret_access_key = ...
region = us-west-2
Option 2: Environment Variables¶
Create a .env file:
# AWS Credentials
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AWS_SESSION_TOKEN=your_session_token # Optional
AWS_REGION=us-east-1
# Optional: Bedrock Configuration
BEDROCK_MODEL_ID=us.anthropic.claude-3-5-sonnet-20240620-v1:0
# For other providers
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
Option 3: DuploCloud (Optional)¶
# Update AWS credentials via DuploCloud
dcaf env-update-aws-creds --tenant=your-tenant --host=https://your-duplo-host.duplocloud.net
1. The Agent¶
The Agent class is the core of DCAF. It orchestrates conversations, manages tool execution, and handles the approval workflow.
Creating an Agent¶
from dcaf.core import Agent
agent = Agent(
tools=[...], # Tools the agent can use
system="You are a helpful assistant", # System prompt
model="anthropic.claude-3-sonnet", # LLM model (optional)
)
Running the Agent¶
from dcaf.core import Agent, ChatMessage
agent = Agent(tools=[...])
# Simple - pass messages
response = agent.run(messages=[
ChatMessage.user("What pods are running?")
])
print(response.text)
# With conversation history
response = agent.run(messages=[
ChatMessage.user("What pods are running?"),
ChatMessage.assistant("There are 3 pods: nginx, redis, api"),
ChatMessage.user("Tell me more about nginx"), # ← Current message
])
Using Plain Dicts (JSON Compatible)¶
You can pass plain dictionaries, useful when receiving messages from JSON APIs:
# From JSON/API request
response = agent.run(messages=[
{"role": "user", "content": "What pods are running?"},
{"role": "assistant", "content": "There are 3 pods..."},
{"role": "user", "content": "Tell me more"},
])
Choosing a Provider¶
DCAF supports multiple LLM providers:
| Provider | Description | Model Examples |
|---|---|---|
bedrock |
AWS Bedrock (default) | anthropic.claude-3-sonnet-20240229-v1:0 |
anthropic |
Direct Anthropic API | claude-3-sonnet-20240229 |
openai |
OpenAI API | gpt-4, gpt-4-turbo, gpt-3.5-turbo |
azure |
Azure OpenAI | Deployment names |
google |
Google AI | gemini-pro |
ollama |
Local Ollama | llama2, mistral, codellama |
# AWS Bedrock (default)
agent = Agent(provider="bedrock", model="anthropic.claude-3-sonnet-20240229-v1:0")
# OpenAI
agent = Agent(provider="openai", model="gpt-4", api_key="sk-...")
# Local Ollama (free, runs locally)
agent = Agent(provider="ollama", model="llama2")
Custom Agent Functions¶
For complex logic beyond simple tool calling, define a custom function:
from dcaf.core import Agent, Session, serve
from dcaf.core.primitives import AgentResult
def my_custom_agent(messages: list, context: dict, session: Session) -> AgentResult:
"""Custom agent with multi-step logic and session access."""
# Track call count in session
call_count = session.get("call_count", 0)
session.set("call_count", call_count + 1)
# Step 1: Classify intent
classifier = Agent(system="Classify the user's intent")
intent = classifier.run(messages).text
# Step 2: Route to appropriate handler
if "kubernetes" in intent.lower():
k8s_agent = Agent(tools=[list_pods, delete_pod])
response = k8s_agent.run(messages, session=session.to_dict())
else:
general_agent = Agent(system="You are a helpful assistant")
response = general_agent.run(messages)
# Return result with updated session
return AgentResult(
text=response.text,
session=session.to_dict(),
)
# Serve the custom function
serve(my_custom_agent, port=8000)
Note: The session parameter is optional for backward compatibility. Functions without it still work.
2. Tools¶
Tools are capabilities your agent can use. DCAF provides three ways to define tool schemas.
Option 1: Auto-Generate (Simplest)¶
Let DCAF generate the schema from your function signature:
from dcaf.tools import tool
@tool(description="Get current weather for a city")
def get_weather(city: str, units: str = "celsius") -> str:
"""Fetch weather data."""
return weather_api.get(city, units)
DCAF automatically creates a JSON schema from the type hints:
{
"type": "object",
"properties": {
"city": {"type": "string"},
"units": {"type": "string", "default": "celsius"}
},
"required": ["city"]
}
Option 2: Dict Schema (Full Control)¶
Define the exact JSON schema yourself:
@tool(
description="Get current weather for a city",
schema={
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name (e.g., 'London', 'New York')"
},
"units": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature units"
}
},
"required": ["city"]
}
)
def get_weather(city: str, units: str = "celsius") -> str:
return weather_api.get(city, units)
Option 3: Pydantic Model (Type-Safe)¶
Use Pydantic for IDE autocomplete, validation, and reusable schemas:
from pydantic import BaseModel, Field
from typing import Literal
class WeatherInput(BaseModel):
"""Schema for weather requests."""
city: str = Field(..., description="City name")
units: Literal["celsius", "fahrenheit"] = Field(
default="celsius",
description="Temperature units"
)
@tool(description="Get current weather", schema=WeatherInput)
def get_weather(city: str, units: str = "celsius") -> str:
return weather_api.get(city, units)
Just pass the Pydantic class - DCAF automatically converts it to JSON schema.
Tool Options¶
| Option | Default | Description |
|---|---|---|
description |
Docstring | What the tool does (shown to LLM) |
requires_approval |
False |
Whether to require human approval |
schema |
Auto-generated | Dict schema OR Pydantic model class |
Complete Tools Example¶
from dcaf.core import Agent, serve
from dcaf.tools import tool
from pydantic import BaseModel, Field
# Auto-generated schema (safe operation)
@tool(description="List Kubernetes pods")
def list_pods(namespace: str = "default") -> str:
return kubectl(f"get pods -n {namespace}")
# Pydantic schema (dangerous operation)
class DeletePodInput(BaseModel):
name: str = Field(..., description="Pod name to delete")
namespace: str = Field(default="default")
force: bool = Field(default=False, description="Force immediate deletion")
@tool(
description="Delete a Kubernetes pod",
requires_approval=True,
schema=DeletePodInput
)
def delete_pod(name: str, namespace: str = "default", force: bool = False) -> str:
cmd = f"kubectl delete pod {name} -n {namespace}"
if force:
cmd += " --force --grace-period=0"
return kubectl(cmd)
# Create and serve the agent
agent = Agent(
tools=[list_pods, delete_pod],
system="You are a Kubernetes assistant."
)
serve(agent, port=8000)
3. Serving Your Agent¶
Expose your agent as a REST API with one line:
Available Endpoints¶
| Endpoint | Method | Description |
|---|---|---|
/health |
GET | Health check |
/api/chat |
POST | Synchronous chat |
/api/chat-stream |
POST | Streaming (NDJSON) |
Testing Your Agent¶
With curl:
curl -X POST http://localhost:8000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "List all pods"}]}'
With Python:
import requests
response = requests.post(
"http://localhost:8000/api/chat",
json={"messages": [{"role": "user", "content": "List all pods"}]}
)
print(response.json())
Streaming Responses¶
For real-time token-by-token output:
curl -X POST http://localhost:8000/api/chat-stream \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "Explain Kubernetes"}]}'
Response (NDJSON):
{"type": "text_delta", "text": "Kubernetes"}
{"type": "text_delta", "text": " is"}
{"type": "text_delta", "text": " a container orchestration platform..."}
{"type": "done"}
Adding Custom Routes¶
from fastapi import APIRouter
custom_router = APIRouter()
@custom_router.get("/custom/status")
def get_status():
return {"status": "operational"}
serve(agent, port=8000, additional_routers=[custom_router])
4. Human-in-the-Loop Approval¶
A core feature of DCAF is requiring human approval for dangerous operations before execution.
Why Approval Matters¶
Autonomous agents executing infrastructure operations is risky:
- Destructive actions:
kubectl delete podoraws ec2 terminate-instances - Irreversible changes: Data deletion, resource destruction
- Compliance: Some environments require human sign-off
- Cost: Expensive operations should be reviewed
Marking Tools for Approval¶
@tool(requires_approval=True, description="Delete a pod")
def delete_pod(name: str, namespace: str = "default") -> str:
return kubectl(f"delete pod {name} -n {namespace}")
The Approval Flow¶
User: "Delete the failing pod"
│
▼
┌──────────────┐
│ Agent calls │
│ delete_pod │
└──────────────┘
│
▼
┌──────────────────────────┐
│ requires_approval=True │
│ → Pause execution │
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ Return pending tool │
│ call to user │
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ User reviews: │
│ [Approve] or [Reject] │
└──────────────────────────┘
│
┌─────┴─────┐
▼ ▼
Approved Rejected
│ │
▼ ▼
Execute Skip tool,
the tool continue
Response Format: Pending Approval¶
When a tool requires approval, the response includes the pending tool call:
{
"role": "assistant",
"content": "I'll delete the pod. This requires your approval.",
"data": {
"tool_calls": [
{
"id": "tc_abc123",
"name": "delete_pod",
"input": {"name": "my-pod", "namespace": "default"},
"execute": false
}
]
}
}
Approving a Tool Call¶
Send back the tool call with execute: true:
{
"messages": [
{
"role": "user",
"content": "",
"data": {
"tool_calls": [
{
"id": "tc_abc123",
"name": "delete_pod",
"input": {"name": "my-pod", "namespace": "default"},
"execute": true
}
]
}
}
]
}
Rejecting a Tool Call¶
To reject, simply don't include it or set execute: false:
Programmatic Handling¶
When using the Agent directly (not via HTTP):
response = agent.run(messages)
if response.needs_approval:
print("Tools pending approval:")
for tool in response.pending_tools:
print(f" - {tool.name}: {tool.input}")
# Option 1: Approve all
response = response.approve_all()
# Option 2: Reject all
response = response.reject_all("User declined")
# Option 3: Handle individually
for tool in response.pending_tools:
if should_approve(tool):
tool.approve()
else:
tool.reject("Not allowed")
response = agent.resume(response.conversation_id)
print(response.text)
Approval Rules¶
Simple rule: Tools with requires_approval=True need human approval before execution.
@tool(requires_approval=True)
def restart_service(name: str) -> str:
"""Restart a service - requires approval."""
return kubectl(f"rollout restart deployment {name}")
5. Session Management¶
Sessions allow you to persist state across conversation turns - perfect for multi-step workflows.
What is a Session?¶
A Session is a key-value store that:
- Persists across turns - Data survives between user messages
- Travels with the protocol - Automatically serialized in responses
- Supports typed models - Store Pydantic models and dataclasses with auto-serialization
- Available everywhere - In tools, interceptors, custom agent functions, and
agent.run()
Using Session in agent.run()¶
You can pass session data directly to agent.run():
from dcaf.core import Agent
agent = Agent(tools=[...])
# Pass session as a dict
response = agent.run(
messages=[{"role": "user", "content": "Continue the wizard"}],
session={"wizard_step": 2, "user_name": "Alice"},
)
# Access updated session from response
print(response.session) # {"wizard_step": 3, ...}
# Pass session to next request
next_response = agent.run(
messages=[{"role": "user", "content": "Next step"}],
session=response.session,
)
Using Session in Tools¶
from dcaf.core import Session
from dcaf.tools import tool
@tool(description="Add item to shopping cart")
def add_to_cart(item: str, quantity: int, session: Session) -> str:
"""Session is automatically injected by DCAF."""
cart = session.get("cart", [])
cart.append({"item": item, "quantity": quantity})
session.set("cart", cart)
return f"Added {quantity}x {item}. Cart now has {len(cart)} items."
@tool(description="View shopping cart")
def view_cart(session: Session) -> str:
cart = session.get("cart", [])
if not cart:
return "Cart is empty"
return "\n".join(f"- {i['quantity']}x {i['item']}" for i in cart)
@tool(description="Checkout")
def checkout(session: Session) -> str:
cart = session.get("cart", [])
total_items = sum(i["quantity"] for i in cart)
session.delete("cart") # Clear after checkout
return f"Checked out {total_items} items!"
Typed Session Storage¶
Store Pydantic models or dataclasses with automatic serialization/deserialization:
from pydantic import BaseModel, Field
from dcaf.core import Session
from dcaf.tools import tool
class CartItem(BaseModel):
name: str
quantity: int
price: float
class ShoppingCart(BaseModel):
items: list[CartItem] = Field(default_factory=list)
discount_code: str | None = None
@tool(description="Add item to cart")
def add_to_cart(name: str, quantity: int, price: float, session: Session) -> str:
# Get as typed model (auto-deserializes from stored JSON)
cart = session.get("cart", as_type=ShoppingCart) or ShoppingCart()
# Work with the typed model
cart.items.append(CartItem(name=name, quantity=quantity, price=price))
# Store it back (auto-serializes to JSON)
session.set("cart", cart)
total = sum(item.price * item.quantity for item in cart.items)
return f"Added {quantity}x {name}. Cart total: ${total:.2f}"
@tool(description="Apply discount code")
def apply_discount(code: str, session: Session) -> str:
cart = session.get("cart", as_type=ShoppingCart)
if not cart:
return "Cart is empty"
cart.discount_code = code
session.set("cart", cart)
return f"Applied discount code: {code}"
Dataclasses work too:
from dataclasses import dataclass
@dataclass
class UserPrefs:
theme: str = "light"
language: str = "en"
notifications: bool = True
# Store dataclass (auto-serializes)
session.set("prefs", UserPrefs(theme="dark"))
# Retrieve as typed (auto-deserializes)
prefs = session.get("prefs", as_type=UserPrefs)
print(prefs.theme) # "dark"
Session API¶
from dcaf.core import Session
session = Session()
# Basic operations
session.set("user_id", "12345")
user_id = session.get("user_id")
session.delete("user_id")
# With defaults
count = session.get("count", 0) # Returns 0 if not set
# Typed retrieval
cart = session.get("cart", as_type=ShoppingCart) # Returns ShoppingCart or None
cart = session.get("cart", ShoppingCart(), as_type=ShoppingCart) # With default
# Check existence
if session.has("user_id"):
...
# Dict-like access
session["key"] = "value"
value = session["key"]
# Iteration
for key in session.keys():
print(key, session[key])
# Bulk operations
session.update({"a": 1, "b": 2})
session.clear()
Session in the Protocol¶
Session data is included in the data.session field of messages:
Response with session:
{
"role": "assistant",
"content": "Added 2x Widget to cart.",
"data": {
"session": {
"cart": [{"item": "Widget", "quantity": 2}],
"user_preference": "dark_mode"
}
}
}
Subsequent request (session travels back):
{
"messages": [
{
"role": "user",
"content": "What's in my cart?",
"data": {
"session": {
"cart": [{"item": "Widget", "quantity": 2}],
"user_preference": "dark_mode"
}
}
}
]
}
Multi-Turn Workflow Example¶
Turn 1:
User: "Add 2 widgets to cart"
Agent: "Added 2x Widget"
Session: {"cart": [{"item": "Widget", "qty": 2}]}
Turn 2:
User: "Add 3 gadgets"
Agent: "Added 3x Gadget. Cart has 2 items."
Session: {"cart": [..., {"item": "Gadget", "qty": 3}]}
Turn 3:
User: "Checkout"
Agent: "Checked out 5 items!"
Session: {} ← Cart cleared
6. Advanced Topics¶
Interceptors¶
Interceptors let you hook into the request/response pipeline. They also have access to session data:
from dcaf.core import Agent, LLMRequest, LLMResponse, InterceptorError
def add_context(request: LLMRequest) -> LLMRequest:
"""Add tenant info and session data before sending to LLM."""
tenant = request.context.get("tenant_name", "unknown")
request.add_system_context(f"User's tenant: {tenant}")
# Access session in interceptor
user_name = request.session.get("user_name", "User")
request.add_system_context(f"User: {user_name}")
# Track request count
count = request.session.get("request_count", 0)
request.session.set("request_count", count + 1)
return request
def redact_secrets(response: LLMResponse) -> LLMResponse:
"""Remove leaked secrets from response."""
response.text = response.text.replace("sk-secret", "[REDACTED]")
# Update session with response metrics
response.session.set("last_response_length", len(response.text))
return response
agent = Agent(
tools=[...],
request_interceptors=[add_context],
response_interceptors=[redact_secrets],
)
See the Interceptors Guide for more details.
Event Handling¶
Subscribe to events for logging, notifications, or audit trails:
def log_events(event):
print(f"[{event.event_type}] at {event.timestamp}")
def notify_slack(event):
if event.event_type == "ApprovalRequested":
slack.post("Approval needed!")
agent = Agent(tools=[...], on_event=[log_events, notify_slack])
Event Types:
ConversationStarted- New conversation beganApprovalRequested- Tools need approvalToolCallApproved- User approved a toolToolCallRejected- User rejected a toolToolExecuted- Tool ran successfullyToolExecutionFailed- Tool execution failed
Troubleshooting¶
AWS Credentials Expired¶
Solution:
# Using DuploCloud
dcaf env-update-aws-creds --tenant=your-tenant --host=your-duplo-host
# Or manually update .env with fresh credentials
Model Not Found¶
Solution:
- Verify the model ID is correct
- Check Bedrock is enabled in your AWS account
- Ensure your region has access to the model
Expected toolResult Blocks¶
Solution:
This occurs when Bedrock receives tool-related messages in an invalid state. DCAF automatically handles this by:
- Filtering tool messages from conversation history
- Limiting parallel tool calls to 1
- Adding a system prompt instruction for single tool calls
Message Alternation Error¶
Solution:
DCAF automatically enforces message alternation. If you see this:
- Check for manual message manipulation
- Ensure you're not passing raw Bedrock-style messages
Connection Timeout¶
Solution:
Import Errors¶
Solution:
Provider Package Missing¶
Solution:
# For OpenAI/Azure
pip install openai
# For Google AI
pip install google-generativeai
# For Ollama
pip install ollama
Next Steps¶
- Core Overview - Full Agent API documentation
- Building Tools - Advanced tool creation
- Custom Agents - Complex multi-step agents
- Server - Deployment and configuration
- Interceptors - Request/response hooks
- Examples - More code examples
Getting Help¶
- Check GitHub Issues
- Enable debug logging:
export LOG_LEVEL=DEBUG - Contact DuploCloud: support@duplocloud.com