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.