Inspiration
M&A deal teams live in their inboxes. NDAs, LOIs, due diligence requests, board communications — all flowing through Gmail, all time-sensitive, all confidential. The question I kept asking was simple: why can't an AI agent handle this?
The answer was always credentials. You cannot hand an AI agent your Gmail password. You cannot let it act autonomously on sensitive communications without a human in the loop.
Auth0 Token Vault changed that equation entirely. When I saw that Token Vault could store and broker OAuth credentials on behalf of an agent, without the agent ever seeing the raw token, I knew exactly what to build.
What it does
VaultDesk is an AI-powered deal room assistant for M&A teams, built on top of Auth0 for AI Agents. It connects to Gmail and Google Calendar via Token Vault and gives deal teams a secure, auditable AI assistant that can:
- Search and read email threads by keyword or sender
- Draft professional replies in your voice
- Send emails only after you approve on your phone via Auth0 CIBA Guardian push notification
- Check calendar availability and list upcoming meetings
- Schedule meetings with CIBA approval — same Guardian push pattern as email sending, any calendar write action requires explicit approval
- Display every active OAuth scope in the sidebar so users always know exactly what the agent can and cannot do
How I built it
The entire application runs as a single Python file: FastAPI handling auth and API routes, Dash serving the UI via WSGIMiddleware, and LangGraph orchestrating the agent.
Auth0 Token Vault integration
The core pattern is simple. On login, Auth0 handles the Google OAuth flow and stores the access token in Token Vault. On every agent tool call, I fetch a fresh token via the Management API:
async def get_google_token(user_id: str) -> str:
async with httpx.AsyncClient() as client:
# Step 1: get management token
r = await client.post(
f"https://{AUTH0_DOMAIN}/oauth/token",
json={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"audience": f"https://{AUTH0_DOMAIN}/api/v2/",
}
)
mgmt_token = r.json()["access_token"]
# Step 2: fetch user identity with Google token
u = await client.get(
f"https://{AUTH0_DOMAIN}/api/v2/users/{user_id}",
headers={"Authorization": f"Bearer {mgmt_token}"},
)
for identity in u.json().get("identities", []):
if identity.get("connection") == "google-oauth2":
return identity["access_token"]
The agent never holds the Google token between requests. Every tool call gets a fresh token from Token Vault. This is the trust boundary Auth0 enforces.
LangGraph agent with 8 tools
TOOLS = [
search_gmail,
get_latest_emails,
read_email_thread,
draft_email_reply,
send_email_with_approval, # CIBA protected
check_calendar,
list_upcoming_events,
create_calendar_event, # CIBA protected
]
graph = StateGraph(AgentState)
graph.add_node("agent", agent_node)
graph.add_node("tools", ToolNode(TOOLS))
graph.set_entry_point("agent")
graph.add_conditional_edges("agent", should_continue)
graph.add_edge("tools", "agent")
agent = graph.compile()
Amazon Nova Lite via ChatBedrockConverse drives the agent at temperature=0 for deterministic tool use.
Auth0 CIBA for human-in-the-loop approval
Both send_email_with_approval and create_calendar_event are CIBA protected. Before any write action executes, the agent triggers Auth0 CIBA:
def ciba_request_approval(action_description: str) -> str:
r = req.post(
f"https://{AUTH0_DOMAIN}/bc-authorize",
data={
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"login_hint": json.dumps({
"format": "iss_sub",
"iss": f"https://{AUTH0_DOMAIN}/",
"sub": _uid
}),
"binding_message": action_description,
"scope": "openid",
}
)
return r.json()["auth_req_id"]
def ciba_poll_approval(auth_req_id: str) -> bool:
while True:
r = req.post(f"https://{AUTH0_DOMAIN}/oauth/token", data={
"grant_type": "urn:openid:params:grant-type:ciba",
"auth_req_id": auth_req_id,
...
})
if r.status_code == 200:
return True
if r.json().get("error") != "authorization_pending":
return False
time.sleep(3)
Auth0 sends a push notification to the user's Guardian app showing exactly what action is being requested. The agent polls until approval or denial. The Gmail send API or Calendar create API is only called on approval.
Challenges I ran into
Token Vault scope management required careful Auth0 dashboard configuration. Getting offline_access and access_type: offline right was essential to ensure tokens did not expire mid-session. The scopes I requested at login had to exactly match what the Gmail and Calendar APIs required:
GOOGLE_SCOPES = " ".join([
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.compose",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events",
])
CIBA binding message validation was a silent failure point. Auth0's /bc-authorize endpoint only accepts alphanumeric characters, whitespace, and +-_.,:#. Any special character in the recipient email caused a 400 with no useful error. I fixed it with explicit sanitization:
safe_to = re.sub(r'[^a-zA-Z0-9]', ' ', to.split('@')[0])[:20].strip()
action_desc = f"Approve send email to {safe_to}"
Nova Lite hallucination at temperature > 0 caused the model to fabricate email content instead of calling read_email_thread. Setting temperature=0 and adding explicit system prompt rules eliminated this:
NEVER write or guess email content from memory.
You have NO knowledge of the user's emails.
To read ANY email: you MUST call read_email_thread with the thread ID.
Dash + FastAPI async was a structural challenge. Dash runs on Flask (WSGI) while FastAPI is ASGI. To show a thinking indicator before the agent responds, I split the flow into two callbacks with a dcc.Interval polling mechanism, resetting n_intervals to 0 on each new message so the interval fires exactly once per request.
LLM date awareness was a subtle bug. Nova Lite has no knowledge of the current date, so when users said "schedule a meeting for today at 1pm" it would hallucinate a date from its training data. I fixed this by injecting the current date and time into the system prompt on every agent call:
def agent_node(state: AgentState):
today = datetime.now().strftime("%Y-%m-%d")
current_time = datetime.now().strftime("%H:%M")
date_context = "\n\nCurrent date: " + today + ". Current time: " + current_time + "."
msgs = [SystemMessage(content=SYSTEM_PROMPT + date_context)] + state["messages"]
return {"messages": [llm_with_tools.invoke(msgs)]}
Accomplishments that I am proud of
The full CIBA loop works end-to-end for both email and calendar. A push notification arrives on Guardian, the user approves, and the action executes. CIBA is applied to every write action that affects other people — sending a message or putting something on their calendar both require explicit Guardian approval.
Zero credential exposure. The agent never holds a Google token in memory between requests. Every tool call fetches a fresh token from Auth0 Token Vault.
The permissions panel in the sidebar shows every active OAuth scope at all times. Transparency is not an afterthought.
The compose modal with 5 email templates (Follow Up, Meeting Request, NDA Acknowledgement, Thank You, Quick Reply) gives deal teams a structured way to draft and send emails without typing everything into a chat box.
What I learned
Token Vault is a trust boundary, not just a credential store. By sitting between the agent and the OAuth provider, it enforces that agents can only act within explicitly granted scopes and that those scopes are visible to the user. This is the pattern agentic AI needs at scale.
CIBA is the right primitive for consequential agent actions. Most developers reach for API keys or session tokens. But for actions like sending communications or scheduling meetings, CIBA's out-of-band approval model turns the user's phone into a hardware security key for every action the agent takes.
LLM temperature matters for tool-calling agents. At $T = 0$, Nova Lite is deterministic and tool-faithful. At $T > 0$, it starts improvising, which is the last thing you want when an agent is reading confidential deal emails.
What's next for VaultDesk
- Cloud Run deployment with containerized, auto-scaling infrastructure
- Multi-user deal rooms with role-based tool access, each member's credentials managed independently via Token Vault
- PDF attachment analysis for due diligence documents
- Full audit log of every agent action with CIBA approval status for compliance reporting
- CIBA approval for email deletion and label management
Blog Post: Building Human-in-the-Loop AI with Auth0 Token Vault and CIBA
This section is submitted for the Auth0 Blog Post bonus prize.
When I started building VaultDesk, the hardest problem was not the AI. It was trust.
An AI agent that reads your Gmail needs your Google credentials. An agent that can send emails on your behalf is a significant security surface. In traditional OAuth flows, you would redirect the user, get a token, store it in your database, and hope your secrets manager is configured correctly. Every step is a potential leak.
Auth0 Token Vault removes that surface entirely. Instead of your application holding the Google access token, Auth0 holds it. Your agent requests it on demand via the Management API, uses it for one operation, and never stores it. The flow looks like this:
User logs in via Auth0 Google OAuth
-> Google access token stored in Token Vault
-> Agent needs to call Gmail API
-> Agent requests token from Token Vault via Management API
-> Management API returns token
-> Agent calls Gmail API
-> Token discarded
-> Next call repeats from step 3
This means even if your application server is compromised, there are no stored Google credentials to steal. The blast radius of a breach is limited to a single in-flight request.
But Token Vault alone is not enough for high-stakes actions. Reading an email is low risk. Sending one is not. Putting a meeting on someone's calendar is not. This is where Auth0 CIBA (Client Initiated Backchannel Authentication) becomes essential.
CIBA lets your agent pause before a consequential action and ask the user for explicit approval on a separate device. In VaultDesk, when the agent is about to send an email or create a calendar event, it calls /bc-authorize with a human-readable binding message. Auth0 sends a push notification to the user's Guardian app. The agent polls /oauth/token waiting for the response. If the user approves, the action executes. If they deny or ignore it, the action is cancelled.
I applied CIBA to two tools: send_email_with_approval and create_calendar_event. Both are write actions that affect other people. Both require the user's phone as a second factor before anything happens. The binding message shown on the Guardian app describes exactly what is about to happen — "Approve send email to ishan" or "Create meeting Deal Review" — so the user is never approving a black box.
The result is an AI agent that is genuinely safe to use with sensitive communications. Not safe because I added a disclaimer, but safe because the architecture makes unauthorized sending or scheduling cryptographically impossible without the user's phone.
This is the future of agentic AI authorization: Token Vault for credential isolation, CIBA for action-level human approval. Auth0 has built exactly the right primitives for this moment.
Built With
- amazon-web-services
- async
- auth0
- fastapi
- langgraph
- nova
- plotlydash
- python
Log in or sign up for Devpost to join the conversation.