Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,17 @@ ZEPTO_MCP_ENABLED=false
# ZEPTO_MCP_CONFIG_DIR=data/credentials/zepto-mcp-remote
# See docs/zepto-mcp.md for security, OAuth, and verification details.

# ---------------------------------------------------------------------------
# Gmail API connector (private Telegram users only)
# ---------------------------------------------------------------------------
# Optional override. Otherwise derive this callback from
# GOOGLE_HEALTH_REDIRECT_URI when it is configured.
# GMAIL_REDIRECT_URI=https://your-domain.example/integrations/gmail/callback
# Reuses GOOGLE_HEALTH_CLIENT_ID, GOOGLE_HEALTH_CLIENT_SECRET, and
# GOOGLE_HEALTH_TOKEN_ENCRYPTION_KEY. See docs/gmail.md for details.
# Optional attachment download limit (default: 25 MiB)
# GMAIL_MAX_ATTACHMENT_BYTES=26214400

# ---------------------------------------------------------------------------
# Observability
# ---------------------------------------------------------------------------
Expand Down
102 changes: 99 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# Google ADK on Bare Metal
# Blacki Architecture and Development Guide

## Philosophy

**Blacki** is a personal assistant agent designed to run on cheap self-hosted infrastructure. The guiding principle: **keep the agent lightweight, delegate heavy lifting to managed services.**
**Blacki** (also called BlackKey) is a Telegram-first personal assistant built on
Google ADK and designed to run on inexpensive self-hosted infrastructure. The
guiding principle is to keep the agent lightweight and delegate expensive work
to managed services.

### Self-Hosted Agent, Managed Tools

Expand Down Expand Up @@ -34,9 +37,73 @@ By delegating to managed services, the agent stays fast, cheap, and reliable. Th
3. **Graceful degradation**: Tools should fail gracefully if API keys are missing
4. **Stateless where possible**: Let managed services handle state

## Architecture

Blacki has a small host-side control plane and several separately authorized
capability boundaries:

- `src/blacki/server.py` starts the FastAPI and ADK runtime, health endpoints,
storage, and optional Telegram polling.
- `src/blacki/agent.py` builds the root agent, model configuration, plugins, and
the public/delegated agent variants.
- `src/blacki/registry.py` is the tool factory. It decides which tools are
exposed for the public runner, the Telegram root agent, and delegated workers.
- `src/blacki/prompt.py` contains global behavior rules. Feature-specific
operating and safety instructions belong in a loaded skill when possible.
- `src/blacki/telegram/` maps private Telegram chats and topics to ADK sessions.
Telegram long polling is outbound and does not require a public webhook.
- SQLite stores application data, session metadata, OAuth state, and catalogs.
Secrets and refresh tokens stay on the Blacki host and are never copied into
a sandbox.

The root agent may receive private, user-scoped integrations such as Google
Health, Gmail, Zepto, TTS, and durable user files. Public ADK requests and
delegated workers receive only the tools explicitly allowed for their boundary.
Shared session-sandbox access does not grant access to another user's accounts.

### File lifecycle and storage boundaries

The sandbox and Cloudflare R2 solve different problems:

- The OpenSandbox instance is session-scoped, created lazily, and temporary.
Its default lifetime is currently 30 minutes. It is the working area for
inspection, parsing, code execution, and intermediate results.
- Cloudflare R2 is optional durable storage for supported Telegram attachments.
When `R2_FILES_ENABLED=true` and the private bucket is configured, Blacki
writes the attachment to R2 and records owner-scoped metadata in SQLite.
- A supported Telegram upload may therefore have both an R2 object and a
sandbox working copy. The copies are independent. If R2 fails, processing
may continue with a temporary sandbox copy and a warning. If the sandbox is
unavailable after R2 succeeds, the durable object can be restored later.
- `list_user_files` lists the owner's R2 catalog, `restore_user_file` copies an
R2 object into the current sandbox, and `delete_user_file` removes an exact
owner-scoped durable object after confirmation.
- Gmail attachment downloads currently write only to the current session
sandbox. They do not automatically go to R2, do not enter the durable file
catalog, and are not sent through Telegram automatically. The Gmail result
files used to keep large email bodies out of model context follow the same
temporary sandbox boundary.

Treat every sandbox file as untrusted input. Do not execute, extract, or open
downloaded attachments automatically. R2 credentials and other provider
credentials must remain outside the sandbox. Any future feature that moves a
file from a sandbox to R2 must explicitly define ownership, retention, naming,
deduplication, failure cleanup, and user-facing consent.

### Gmail boundary

Gmail is a direct Gmail REST API connector under `src/blacki/gmail/`, not a
general-purpose shared service. OAuth credentials are stored per private
Telegram user, Gmail tools are registered only for the private root-agent
flow, and delegated workers remain unable to call Gmail. The Gmail skill in
`src/blacki/skills/gmail/SKILL.md` supplies the agent-facing usage and safety
rules; loading the skill is not itself an authorization grant.

## Project Overview

**Google ADK on Bare Metal** is a production-ready template designed for building and deploying AI agents using the Google Agent Development Kit (ADK) on self-hosted infrastructure. It removes cloud provider lock-in by providing a clean, performant, and observable foundation that runs on bare metal, VPS, or private clouds.
Blacki is a production-ready personal assistant built with the Google Agent
Development Kit (ADK) on self-hosted infrastructure. It provides a clean,
observable foundation that can run on bare metal, a VPS, or a private cloud.

### Key Technologies
* **Language:** Python 3.13+
Expand Down Expand Up @@ -80,6 +147,35 @@ By delegating to managed services, the agent stays fast, cheap, and reliable. Th

## Development Conventions

### Required cross-feature impact review

Before implementing or materially changing a feature, inspect the existing
features and data paths that it may touch. The implementation agent must
actively consider at least:

- user identity and authorization boundaries;
- Telegram, ADK session, and delegated-worker behavior;
- sandbox lifetime and whether a file is temporary or durable;
- Cloudflare R2 storage, catalog, retention, restore, and deletion behavior;
- OAuth scopes, token ownership, external APIs, and privacy implications;
- model context size, logs, traces, and whether content can leak across users;
- confirmation requirements for state-changing operations; and
- failure and partial-success behavior when one storage or provider is down.

Do not assume that a new file-producing feature should use the same lifecycle
as Telegram uploads. If the request does not specify whether a new artifact is
sandbox-only, copied to R2, sent to Telegram, or retained elsewhere, complete
the read-only investigation first and ask the user one focused question before
choosing a persistence or delivery policy. Explain the current behavior and
the available options so the user can decide. If the user has already decided,
state that assumption in the implementation summary and verify every affected
boundary.

For every new integration, identify which existing agents and toolsets should
see it, whether it should be skill-gated, and how it interacts with existing
storage and privacy controls. Do not broaden a feature's data retention or
external delivery merely because another feature already does so.

### Code Structure
* **`src/blacki/`**: Contains the core agent logic.
* `agent.py`: Defines the `root_agent` and ADK application configuration.
Expand Down
67 changes: 67 additions & 0 deletions docs/gmail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Gmail API connector

Blacki uses Google's Gmail REST API for a connected private Telegram user. It
does not use a shared credential file.

## Configuration

Enable the connector only when the server is ready to store restricted Gmail
data:

```dotenv
GOOGLE_HEALTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_HEALTH_CLIENT_SECRET=your-client-secret
GOOGLE_HEALTH_TOKEN_ENCRYPTION_KEY=your-fernet-key
GMAIL_REDIRECT_URI=https://your-domain.example/integrations/gmail/callback
# Optional download limit, default 25 MiB
GMAIL_MAX_ATTACHMENT_BYTES=26214400
```

`GMAIL_REDIRECT_URI` is optional. When it is not set, Blacki derives the Gmail
callback from `GOOGLE_HEALTH_REDIRECT_URI`, or uses the local callback on port
8080. The OAuth client must register the exact callback URL.

There is no separate Gmail feature flag. Blacki configures the connector when
the shared Google OAuth values are present and keeps it unavailable when they
are missing or invalid.

Blacki requests only
[`https://www.googleapis.com/auth/gmail.modify`](https://developers.google.com/workspace/gmail/api/auth/scopes).
Refresh tokens are encrypted with the existing Google Health Fernet key and
stored in the shared SQLite database. OAuth state is stored as a hash, expires,
and can be consumed only once.

## Telegram flow

1. Send `/connect_gmail` in a private Telegram chat.
2. Authorize the requested Gmail scope in Google's consent screen.
3. Use the Gmail skill after the connection message arrives.
4. Send `/disconnect_gmail` and confirm when access should be revoked.

Each private Telegram chat has its own connection. Group chats, public agents,
delegated workers, local test identities, and unconnected users receive no
Gmail tools.

## Supported operations

The connector can search non-spam, non-trash messages, read bounded message and
thread bodies, list and read drafts, create drafts and replies, list and create
custom labels, modify custom labels, and download requested attachments into the
current session sandbox. Downloads return only safe metadata and expire with the
sandbox. They are not copied to durable storage or sent through Telegram.
Large message and thread bodies are also materialized in the current session
sandbox when their tool results would otherwise be large. The Gmail tool returns
the sandbox path so the agent can inspect the content incrementally.

Sending requires an explicit Google ADK confirmation. The confirmation includes
the draft ID, recipients, and subject. Blacki reloads the draft immediately
before sending and stops if those values no longer match.

The connector does not delete messages, access or modify spam and trash, or
change Gmail settings. Downloaded attachments are untrusted files and Blacki
does not automatically open, extract, or execute them. Retrieved email content
is private and may pass through Blacki's configured LLM and conversation storage.

Google's [OAuth token expiration guidance](https://developers.google.com/identity/protocols/oauth2#expiration)
applies while the OAuth application is in testing. Test users may need to
reconnect periodically.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ nav:
- First VPS deployment: DEPLOYMENT.md
- Telegram setup: telegram-setup.md
- Zepto MCP: zepto-mcp.md
- Gmail API: gmail.md
- Local development: development.md
- Operate:
- Configuration: base-infra/environment-variables.md
Expand Down
53 changes: 42 additions & 11 deletions src/blacki/adk_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,23 +164,54 @@ def _pending_confirmations(session: Session) -> list[PendingConfirmation]:
def _format_confirmation(
confirmation: PendingConfirmation, *, pending_count: int
) -> str:
args = json.dumps(
confirmation.tool_args,
indent=2,
sort_keys=True,
ensure_ascii=False,
)
if confirmation.tool_name == "gmail_send_draft":
args = confirmation.tool_args
details = [
f"draft `{_confirmation_value(args.get('draft_id'))}`",
f"to `{_confirmation_value(args.get('expected_to'))}`",
]
for key, label in (
("expected_cc", "cc"),
("expected_bcc", "bcc"),
):
value = _confirmation_value(args.get(key))
if value:
details.append(f"{label} `{value}`")
details.append(f"subject `{_confirmation_value(args.get('expected_subject'))}`")
confirmation_text = (
"Confirm sending Gmail "
+ ", ".join(details)
+ "? Reply exactly `yes` or `no`."
)
else:
serialized_args = json.dumps(
confirmation.tool_args,
indent=2,
sort_keys=True,
ensure_ascii=False,
)
confirmation_text = (
f"Confirm Zepto tool `{confirmation.tool_name}` with these arguments?\n\n"
f"```json\n{serialized_args}\n```\n\nReply exactly `yes` or `no`."
)
suffix = ""
if pending_count > 1:
suffix = (
f"\n\nThere are {pending_count} pending calls. This reply applies "
"only to the first; each call must be confirmed separately."
)
return (
f"Confirm Zepto tool `{confirmation.tool_name}` with these arguments?\n\n"
f"```json\n{args}\n```\n\nReply exactly `yes` or `no`."
f"{suffix}"
)
return f"{confirmation_text}{suffix}"


def _confirmation_value(value: object, *, max_length: int = 160) -> str:
"""Render bounded confirmation metadata without body or markup injection."""
if value is None:
return ""
text = " ".join(str(value).split())
text = text.replace("`", "'")
if len(text) > max_length:
text = text[: max_length - 1] + "…"
return text


def _confirmation_response(
Expand Down
18 changes: 18 additions & 0 deletions src/blacki/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

from blacki.calories.storage import SqliteCalorieStorage
from blacki.declarative_db.storage import SqliteDeclarativeDbStorage
from blacki.gmail.storage import SqliteGmailStorage
from blacki.health.nutrition_worker import NutritionExportWorker
from blacki.health.storage import SqliteGoogleHealthStorage
from blacki.reminders.storage import SqliteReminderStorage
Expand Down Expand Up @@ -145,6 +146,9 @@ class AppContainer:
_google_health_storage: SqliteGoogleHealthStorage | None = field(
default=None, init=False, repr=False
)
_gmail_storage: SqliteGmailStorage | None = field(
default=None, init=False, repr=False
)
_user_file_storage: SqliteUserFileStorage | None = field(
default=None, init=False, repr=False
)
Expand Down Expand Up @@ -206,6 +210,10 @@ async def _close_storages(self) -> None:
await self._google_health_storage.close()
self._google_health_storage = None

if self._gmail_storage is not None:
await self._gmail_storage.close()
self._gmail_storage = None

if self._user_file_storage is not None:
await self._user_file_storage.close()
self._user_file_storage = None
Expand All @@ -226,6 +234,7 @@ async def initialize_all_storages(self) -> None:
await self.preferences_storage.initialize()
await self.declarative_db_storage.initialize()
await self.google_health_storage.initialize()
await self.gmail_storage.initialize()
await self.user_file_storage.initialize()
await self.telegram_access_storage.initialize()

Expand Down Expand Up @@ -292,6 +301,15 @@ def google_health_storage(self) -> SqliteGoogleHealthStorage:
)
return self._google_health_storage

@property
def gmail_storage(self) -> SqliteGmailStorage:
"""Get or create storage for Gmail OAuth state and connections."""
if self._gmail_storage is None:
from blacki.gmail.storage import SqliteGmailStorage

self._gmail_storage = SqliteGmailStorage(self.conn, self._lock)
return self._gmail_storage

@property
def user_file_storage(self) -> SqliteUserFileStorage:
"""Get or create the durable user-file catalog."""
Expand Down
Loading
Loading