Skip to content
Closed
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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ 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)
# ---------------------------------------------------------------------------
# Keep Gmail's OAuth client, secret, and token-encryption key separate from
# Google Health. Enable this explicitly only after the Gmail OAuth app is ready.
# GMAIL_ENABLED=false
# GMAIL_CLIENT_ID=your-gmail-client-id.apps.googleusercontent.com
# GMAIL_CLIENT_SECRET=your-gmail-client-secret
# GMAIL_TOKEN_ENCRYPTION_KEY=your-gmail-fernet-key
# GMAIL_REDIRECT_URI=https://your-domain.example/integrations/gmail/callback
# 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
16 changes: 16 additions & 0 deletions docs/base-infra/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ health-metrics/measurements, and sleep scopes plus
read-only scopes support summaries. Both nutrition scopes are
required for automatic export of future private-chat meal logs, edits, and
deletions. Existing connections must reconnect to request the added scopes;

Blacki never backfills meals logged before consent. Do not paste the client
secret or Fernet key into logs, chat, or source control. The callback URL must
exactly match the Google Cloud OAuth client configuration. The Apple
Expand All @@ -164,6 +165,21 @@ scopes configured and the resulting consent is verified.
[health-nutrition]: https://developers.google.com/health/data-types/nutrition
[health-datapoints]: https://developers.google.com/health/reference/rest/v4/users.dataTypes.dataPoints

### Gmail API connector

The connector is disabled unless `GMAIL_ENABLED=true`. It uses a separate OAuth
client and token-encryption key from Google Health and is exposed only to the
private Telegram root agent after the user connects an account.

| Variable | Default | Purpose |
| --- | --- | --- |
| `GMAIL_ENABLED` | `false` | Explicitly enable the Gmail connector |
| `GMAIL_CLIENT_ID` | unset | Dedicated server-side Gmail OAuth web-client ID |
| `GMAIL_CLIENT_SECRET` | unset | Dedicated server-side Gmail OAuth web-client secret |
| `GMAIL_REDIRECT_URI` | `http://127.0.0.1:8080/integrations/gmail/callback` | Gmail OAuth callback; use HTTPS in production |
| `GMAIL_TOKEN_ENCRYPTION_KEY` | unset | Dedicated Fernet key for Gmail refresh tokens |
| `GMAIL_MAX_ATTACHMENT_BYTES` | `26214400` | Maximum Gmail attachment download size |

## Search and browser tools

| Variable | Default | Purpose |
Expand Down
70 changes: 70 additions & 0 deletions docs/gmail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 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
GMAIL_ENABLED=true
GMAIL_CLIENT_ID=your-gmail-client-id.apps.googleusercontent.com
GMAIL_CLIENT_SECRET=your-gmail-client-secret
GMAIL_TOKEN_ENCRYPTION_KEY=your-gmail-fernet-key
GMAIL_REDIRECT_URI=https://your-domain.example/integrations/gmail/callback
# Optional download limit, default 25 MiB
GMAIL_MAX_ATTACHMENT_BYTES=26214400
```

`GMAIL_ENABLED` is required and defaults to false. Gmail uses a dedicated OAuth
client, token-encryption key, and callback configuration. `GMAIL_REDIRECT_URI`
is optional and otherwise uses the local callback on port 8080. The Gmail OAuth
client must register the exact callback URL.

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 dedicated Gmail 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.

If Google cannot complete revocation, Blacki disables local Gmail use and
retains the encrypted credential so the disconnect can be retried. It does not
report a successful disconnect until remote revocation succeeds.

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, subject, and a content fingerprint returned by the
draft read/create operation. Blacki reloads the draft immediately before
sending and stops if any of 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
58 changes: 47 additions & 11 deletions src/blacki/adk_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,23 +164,59 @@ 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'))}`")
fingerprint = _confirmation_value(
args.get("expected_content_fingerprint"), max_length=16
)
if fingerprint:
details.append(f"content fingerprint `{fingerprint}`")
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