Skip to main content
Human-in-the-loop (HITL) allows you to require human approval before executing sensitive tool calls. When a tool marked as requiring approval is invoked by the agent, the execution pauses and waits for explicit user confirmation before proceeding.

How It Works

  1. Tool invocation: The agent decides to call a tool that requires approval
  2. Execution pauses: The run transitions to an await_approval state
  3. User reviews: The pending tool call is presented to the user for review
  4. User responds: The user approves or rejects the tool call
  5. Execution resumes: The agent continues with the approved tools or handles rejections

Configuring Approval for Custom Tools

To require approval for a custom function tool, embed core.BaseTool and set RequiresApproval: true:
type DeleteUserTool struct {
    *core.BaseTool
}

func NewDeleteUserTool() *DeleteUserTool {
    return &DeleteUserTool{
        BaseTool: &core.BaseTool{
            RequiresApproval: true,  // Requires human approval
            ToolUnion: responses.ToolUnion{
                OfFunction: &responses.FunctionTool{
                    Name:        "delete_user",
                    Description: utils.Ptr("Permanently deletes a user account"),
                    Parameters: map[string]any{
                        "type": "object",
                        "properties": map[string]any{
                            "user_id": map[string]any{
                                "type":        "string",
                                "description": "The ID of the user to delete",
                            },
                        },
                        "required": []string{"user_id"},
                    },
                },
            },
        },
    }
}

func (t *DeleteUserTool) Execute(ctx context.Context, params *responses.FunctionCallMessage) (*responses.FunctionCallOutputMessage, error) {
    // This only runs after human approval
    args := map[string]interface{}{}
    json.Unmarshal([]byte(params.Arguments), &args)
    
    userID := args["user_id"].(string)
    err := deleteUser(userID)
    
    return &responses.FunctionCallOutputMessage{
        ID:     params.ID,
        CallID: params.CallID,
        Output: responses.FunctionCallOutputContentUnion{
            OfString: utils.Ptr(fmt.Sprintf("User %s deleted successfully", userID)),
        },
    }, err
}

Configuring Approval for MCP Tools

For MCP tools, use the WithMcpApprovalRequiredTools option when retrieving tools:
mcpClient, err := mcpclient.NewSSEClient(context.Background(), "http://localhost:9001/sse",
		mcpclient.WithHeaders(map[string]string{
			"token": "your-token",
		}),
		mcpclient.WithToolFilter("list_users"),
		mcpclient.WithApprovalRequiredTools("list_users"),
	)
if err != nil {
    log.Fatal(err)
}

agent := client.NewAgent(&sdk.AgentOptions{
    Name:        "Assistant",
    Instruction: client.Prompt("You are a helpful assistant."),
    LLM:         model,
    McpServers:  []agents.MCPToolset{mcpClient},
})

Handling Approval in Your Application

When a tool requires approval, the agent execution pauses and returns with a paused status. The pending tool calls are available in the run state.

Run State

The run state includes pending tool calls when approval is needed:
type RunState struct {
    CurrentStep      Step                            `json:"current_step"`       // "await_approval"
    PendingToolCalls []responses.FunctionCallMessage `json:"pending_tool_calls"` // Tools awaiting approval
    // ...
}

Resuming Execution

To resume execution after user review, send a FunctionCallApprovalResponseMessage:
// User approved the tool call
approvalResponse := responses.InputMessageUnion{
    OfFunctionCallApprovalResponse: &responses.FunctionCallApprovalResponseMessage{
        ID:              uuid.NewString(),
        ApprovedCallIds: []string{pendingToolCall.CallID},  // Approved tool call IDs
        RejectedCallIds: []string{},                        // Rejected tool call IDs
    },
}

// Resume execution with the approval response
result, err := agent.Execute(ctx, &agents.AgentInput{
    Messages: []responses.InputMessageUnion{
        approvalResponse,
    },
})

Handling Rejections

When a tool call is rejected, the agent receives a message indicating the rejection and can respond appropriately:
// User rejected the tool call
approvalResponse := responses.InputMessageUnion{
    OfFunctionCallApprovalResponse: &responses.FunctionCallApprovalResponseMessage{
        ID:              uuid.NewString(),
        ApprovedCallIds: []string{},
        RejectedCallIds: []string{pendingToolCall.CallID},
    },
}
The agent will receive the rejection as tool output: "Request to call this tool has been declined" and can adjust its response accordingly.

Complete Example

Here’s a complete example with a mix of immediate and approval-required tools:
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"os"

	"github.com/bytedance/sonic"
	"github.com/google/uuid"
	"github.com/curaious/uno/internal/utils"
	"github.com/curaious/uno/pkg/agent-framework/agents"
	"github.com/curaious/uno/pkg/agent-framework/core"
	"github.com/curaious/uno/pkg/gateway"
	"github.com/curaious/uno/pkg/llm"
	"github.com/curaious/uno/pkg/llm/responses"
	"github.com/curaious/uno/pkg/sdk"
)

// GetUserTool - runs immediately (no approval needed)
type GetUserTool struct {
	*core.BaseTool
}

func NewGetUserTool() *GetUserTool {
	return &GetUserTool{
		BaseTool: &core.BaseTool{
			RequiresApproval: false,
			ToolUnion: responses.ToolUnion{
				OfFunction: &responses.FunctionTool{
					Name:        "get_user",
					Description: utils.Ptr("Gets user information"),
					Parameters: map[string]any{
						"type": "object",
						"properties": map[string]any{
							"user_id": map[string]any{"type": "string"},
						},
						"required": []string{"user_id"},
					},
				},
			},
		},
	}
}

func (t *GetUserTool) Execute(ctx context.Context, params *responses.FunctionCallMessage) (*responses.FunctionCallOutputMessage, error) {
	return &responses.FunctionCallOutputMessage{
		ID:     params.ID,
		CallID: params.CallID,
		Output: responses.FunctionCallOutputContentUnion{
			OfString: utils.Ptr(`{"name": "John Doe", "email": "[email protected]"}`),
		},
	}, nil
}

// DeleteUserTool - requires approval
type DeleteUserTool struct {
	*core.BaseTool
}

func NewDeleteUserTool() *DeleteUserTool {
	return &DeleteUserTool{
		BaseTool: &core.BaseTool{
			RequiresApproval: true, // Human approval required
			ToolUnion: responses.ToolUnion{
				OfFunction: &responses.FunctionTool{
					Name:        "delete_user",
					Description: utils.Ptr("Permanently deletes a user account"),
					Parameters: map[string]any{
						"type": "object",
						"properties": map[string]any{
							"user_id": map[string]any{"type": "string"},
						},
						"required": []string{"user_id"},
					},
				},
			},
		},
	}
}

func (t *DeleteUserTool) Execute(ctx context.Context, params *responses.FunctionCallMessage) (*responses.FunctionCallOutputMessage, error) {
	args := map[string]any{}
	json.Unmarshal([]byte(params.Arguments), &args)

	return &responses.FunctionCallOutputMessage{
		ID:     params.ID,
		CallID: params.CallID,
		Output: responses.FunctionCallOutputContentUnion{
			OfString: utils.Ptr(fmt.Sprintf("User %s has been deleted", args["user_id"])),
		},
	}, nil
}

func main() {
	ctx := context.Background()

	client, err := sdk.New(&sdk.ClientOptions{
		LLMConfigs: sdk.NewInMemoryConfigStore([]*gateway.ProviderConfig{
			{
				ProviderName:  llm.ProviderNameOpenAI,
				BaseURL:       "",
				CustomHeaders: nil,
				ApiKeys: []*gateway.APIKeyConfig{
					{
						Name:   "Key 1",
						APIKey: os.Getenv("OPENAI_API_KEY"),
					},
				},
			},
		}),
	})
	if err != nil {
		log.Fatal(err)
	}

	agent := client.NewAgent(&sdk.AgentOptions{
		Name:        "User Manager",
		Instruction: client.Prompt("You help manage user accounts."),
		LLM: client.NewLLM(sdk.LLMOptions{
			Provider: llm.ProviderNameOpenAI,
			Model:    "gpt-4o-mini",
		}),
		Tools: []core.Tool{
			NewGetUserTool(),
			NewDeleteUserTool(),
		},
		History: client.NewConversationManager(),
	})

	// First execution - agent may request to delete a user
	result, err := agent.Execute(ctx, &agents.AgentInput{
		Namespace: "default",
		Messages: []responses.InputMessageUnion{
			responses.UserMessage("Delete user 123"),
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	// Check if approval is needed
	if result.Status == core.RunStatusPaused {
		fmt.Println("Approval required for:", result.PendingApprovals)

		// Simulate user approval
		approvalResponse := responses.InputMessageUnion{
			OfFunctionCallApprovalResponse: &responses.FunctionCallApprovalResponseMessage{
				ID:              uuid.NewString(),
				ApprovedCallIds: []string{result.PendingApprovals[0].CallID},
				RejectedCallIds: []string{},
			},
		}

		// Resume with approval
		result, err = agent.Execute(ctx, &agents.AgentInput{
			Namespace:         "default",
			PreviousMessageID: result.RunID,
			Messages:          []responses.InputMessageUnion{approvalResponse},
		})
	}

	if err != nil {
		log.Fatal(err)
	}

	buf, _ := sonic.Marshal(result.Output)
	fmt.Println("Final result:", string(buf))
}