Permission & Interaction Registries
When your PermissionPolicy returns AskUser, the agent pauses and waits for a response through the registry system. The PermissionRegistry handles tool permission requests, while the UserInteractionRegistry handles questions from the ask_user_questions tool. Understanding these registries is essential for server integrations that need to collect user decisions.
The registry pattern decouples request initiation from response handling. Tools register their requests and block waiting for responses, while your application collects decisions from wherever appropriate—user interfaces, automated systems, or fallback logic—and submits them through the registry.
PermissionRegistry
The PermissionRegistry manages permission requests for tool operations. When a tool needs permission and your policy returns AskUser, the request is registered and the tool blocks until you provide a response.
Getting the Registry
Access the registry from the AgentAir instance:
let permission_registry = agent.permission_registry();
The registry is wrapped in Arc, so you can clone it for use in multiple tasks.
Responding to Permission Requests
When you receive a PermissionRequired event, respond through the registry:
use agent_air::{PermissionPanelResponse, PermissionScope};
match event {
UiMessage::PermissionRequired { tool_use_id, request, session_id } => {
// Collect decision from user or automated system
let approved = collect_permission_decision(&request).await;
let response = if approved {
PermissionPanelResponse::allow_with_scope(PermissionScope::Once)
} else {
PermissionPanelResponse::deny("User declined permission")
};
permission_registry
.respond_to_request(&tool_use_id, response)
.await;
}
// ...
}
The tool_use_id correlates the response with the waiting request. The tool resumes immediately after you call respond_to_request.
PermissionPanelResponse
The response struct specifies whether to allow or deny, and for how long:
pub struct PermissionPanelResponse {
pub granted: bool,
pub scope: Option<PermissionScope>,
pub message: Option<String>,
}
pub enum PermissionScope {
Once, // This operation only
Session, // All similar operations this session
}
Use the constructor methods for clarity:
// Allow this one operation
PermissionPanelResponse::allow_with_scope(PermissionScope::Once)
// Allow all similar operations this session
PermissionPanelResponse::allow_with_scope(PermissionScope::Session)
// Deny with explanation
PermissionPanelResponse::deny("Access to /etc is not permitted")
Session-Scoped Permissions
When you grant PermissionScope::Session, subsequent requests matching the same pattern are automatically approved without generating new events. This reduces friction for workflows that involve repeated operations.
// First request for writing to /project/*
// User grants Session scope
// Later requests to /project/src/main.rs, /project/README.md, etc.
// Auto-approved, no PermissionRequired event
The matching considers:
- Target type (Path, Domain, Command)
- Target pattern (path prefix, domain, command name)
- Permission level (Read, Write, Execute, Admin)
Batch Permission Handling
When the LLM requests multiple tools simultaneously, permissions are batched into a single BatchPermissionRequired event. Respond with decisions for all requests at once.
Receiving Batch Requests
match event {
UiMessage::BatchPermissionRequired { batch_id, requests, session_id } => {
// Collect decisions for all requests
let decisions = collect_batch_decisions(&requests).await;
let response = BatchPermissionResponse {
decisions: decisions.into_iter().enumerate().map(|(i, approved)| {
RequestDecision {
request_index: i,
response: if approved {
PermissionPanelResponse::allow_with_scope(PermissionScope::Once)
} else {
PermissionPanelResponse::deny("Denied by policy")
},
}
}).collect(),
};
permission_registry
.respond_to_batch(&batch_id, response)
.await;
}
// ...
}
Partial Approvals
Users can approve some requests and deny others. Approved tools execute; denied tools return errors to the LLM.
let response = BatchPermissionResponse {
decisions: vec![
RequestDecision {
request_index: 0,
response: PermissionPanelResponse::allow_with_scope(PermissionScope::Once),
},
RequestDecision {
request_index: 1,
response: PermissionPanelResponse::deny("Write access denied"),
},
RequestDecision {
request_index: 2,
response: PermissionPanelResponse::allow_with_scope(PermissionScope::Once),
},
],
};
UserInteractionRegistry
The UserInteractionRegistry handles questions from the ask_user_questions tool. When this tool is invoked, the agent pauses and waits for answers through the registry.
Getting the Registry
Access the registry from the AgentAir instance:
let user_interaction_registry = agent.user_interaction_registry();
Responding to Questions
When you receive a UserInteractionRequired event, collect answers and respond:
use agent_air::AskUserQuestionsResponse;
match event {
UiMessage::UserInteractionRequired { tool_use_id, request, session_id } => {
// Display questions and collect answers
let answers = collect_user_answers(&request.questions).await;
let response = AskUserQuestionsResponse {
answers,
cancelled: false,
};
user_interaction_registry
.respond(&tool_use_id, response)
.await;
}
// ...
}
Question Types
The request contains structured questions with different answer types:
pub struct Question {
pub id: String,
pub text: String,
pub question_type: QuestionType,
pub options: Option<Vec<String>>,
pub required: bool,
}
pub enum QuestionType {
SingleChoice,
MultipleChoice,
FreeText,
}
Render questions appropriately based on their type:
- SingleChoice: Radio buttons or single-select dropdown
- MultipleChoice: Checkboxes or multi-select
- FreeText: Text input field
Answer Format
Answers are a map from question ID to response:
use std::collections::HashMap;
let mut answers = HashMap::new();
answers.insert("q1".to_string(), json!("Option A")); // Single choice
answers.insert("q2".to_string(), json!(["Option B", "Option C"])); // Multiple choice
answers.insert("q3".to_string(), json!("Free form text")); // Free text
Cancellation
If the user cancels without answering, set cancelled: true:
let response = AskUserQuestionsResponse {
answers: HashMap::new(),
cancelled: true,
};
The tool receives an indication that the user cancelled, allowing the LLM to handle the situation appropriately.
Handling Without User Input
In fully automated scenarios, you may need to handle these events without real user input. Several strategies work depending on your requirements.
Auto-Cancel Interactions
If your policy doesn’t support user interaction, cancel immediately:
match event {
UiMessage::UserInteractionRequired { tool_use_id, .. } => {
// Can't collect user input in this environment
let response = AskUserQuestionsResponse {
answers: HashMap::new(),
cancelled: true,
};
user_interaction_registry.respond(&tool_use_id, response).await;
}
// ...
}
Provide Default Answers
Supply default answers based on question metadata:
fn generate_default_answers(questions: &[Question]) -> HashMap<String, Value> {
let mut answers = HashMap::new();
for question in questions {
let answer = match question.question_type {
QuestionType::SingleChoice => {
// Pick first option
question.options.as_ref()
.and_then(|opts| opts.first())
.map(|s| json!(s))
.unwrap_or(json!(null))
}
QuestionType::MultipleChoice => json!([]),
QuestionType::FreeText => json!(""),
};
if question.required || answer != json!(null) {
answers.insert(question.id.clone(), answer);
}
}
answers
}
Forward to External System
Route questions to an external decision system:
async fn handle_with_external_system(
tool_use_id: &str,
request: &AskUserQuestionsRequest,
registry: &UserInteractionRegistry,
) {
// Send to external system
let external_response = external_system.ask(request).await;
let response = match external_response {
Ok(answers) => AskUserQuestionsResponse {
answers,
cancelled: false,
},
Err(_) => AskUserQuestionsResponse {
answers: HashMap::new(),
cancelled: true,
},
};
registry.respond(tool_use_id, response).await;
}
Timeout Handling
Add timeouts to prevent indefinite blocking when users don’t respond:
use tokio::time::{timeout, Duration};
async fn collect_with_timeout(
tool_use_id: &str,
request: &PermissionRequest,
registry: &PermissionRegistry,
) {
let result = timeout(
Duration::from_secs(300), // 5 minute timeout
collect_permission_decision(request)
).await;
let response = match result {
Ok(approved) => {
if approved {
PermissionPanelResponse::allow_with_scope(PermissionScope::Once)
} else {
PermissionPanelResponse::deny("User declined")
}
}
Err(_) => {
// Timeout - deny by default
PermissionPanelResponse::deny("Request timed out")
}
};
registry.respond_to_request(tool_use_id, response).await;
}
Session Cleanup
When sessions end, clean up any pending requests:
async fn cleanup_session(
session_id: i64,
permission_registry: &PermissionRegistry,
user_interaction_registry: &UserInteractionRegistry,
) {
// Cancel any pending permission requests for this session
permission_registry.cancel_session(session_id).await;
// Cancel any pending user interactions for this session
user_interaction_registry.cancel_session(session_id).await;
}
Call this when users disconnect, sessions time out, or the server shuts down. Pending requests receive cancellation errors, unblocking any waiting tools.
Complete Example
A full event handler that manages both registries:
struct ServerEventHandler {
permission_registry: Arc<PermissionRegistry>,
user_interaction_registry: Arc<UserInteractionRegistry>,
user_notifier: UserNotifier,
}
impl ServerEventHandler {
async fn handle_event(&self, event: UiMessage) {
match event {
UiMessage::PermissionRequired { tool_use_id, request, session_id } => {
// Notify user
self.user_notifier.send_permission_request(session_id, &request).await;
// Wait for response (with timeout)
let approved = timeout(
Duration::from_secs(60),
self.user_notifier.wait_for_permission_response(session_id, &tool_use_id)
).await.unwrap_or(false);
let response = if approved {
PermissionPanelResponse::allow_with_scope(PermissionScope::Once)
} else {
PermissionPanelResponse::deny("Permission denied or timed out")
};
self.permission_registry
.respond_to_request(&tool_use_id, response)
.await;
}
UiMessage::BatchPermissionRequired { batch_id, requests, session_id } => {
self.user_notifier.send_batch_permission_request(session_id, &requests).await;
let decisions = timeout(
Duration::from_secs(120),
self.user_notifier.wait_for_batch_response(session_id, &batch_id)
).await.unwrap_or_else(|_| {
// Timeout - deny all
requests.iter().enumerate().map(|(i, _)| {
RequestDecision {
request_index: i,
response: PermissionPanelResponse::deny("Timed out"),
}
}).collect()
});
self.permission_registry
.respond_to_batch(&batch_id, BatchPermissionResponse { decisions })
.await;
}
UiMessage::UserInteractionRequired { tool_use_id, request, session_id } => {
self.user_notifier.send_questions(session_id, &request).await;
let result = timeout(
Duration::from_secs(300),
self.user_notifier.wait_for_answers(session_id, &tool_use_id)
).await;
let response = match result {
Ok(answers) => AskUserQuestionsResponse { answers, cancelled: false },
Err(_) => AskUserQuestionsResponse { answers: HashMap::new(), cancelled: true },
};
self.user_interaction_registry
.respond(&tool_use_id, response)
.await;
}
// Forward other events to clients
other => {
self.user_notifier.forward_event(other).await;
}
}
}
}
This handler notifies users of pending decisions, waits for responses with timeouts, and ensures the agent always receives a response to unblock tool execution.
