···1010- [Clickhouse](https://clickhouse.com/)
11111212Phoebe can be used with Anthropic, OpenAI, and OpenAPI-compatible model APIs.
1313+1414+## Usage
1515+1616+TBD. Right now, you can talk to the agent through `uv run main.py chat` and ask for assistance with Osprey-related tasks.
···5252 if tools:
5353 kwargs["tools"] = tools
54545555- return await self._client.messages.create(**kwargs) # type: ignore
5555+ async with self._client.messages.stream(**kwargs) as stream: # type: ignore
5656+ return await stream.get_final_message()
565757585859class Agent:
···9192 return {"error": f"Unknown tool: {tool_use.name}"}
92939394 async def chat(self, user_message: str) -> str:
9494- """Send a message and get a response, handling tool calls."""
9595+ """send a message and get a response, handling tool calls"""
9596 self._conversation.append({"role": "user", "content": user_message})
96979798 while True:
···126127 tool_results: list[dict[str, Any]] = []
127128 for block in resp.content:
128129 if isinstance(block, ToolUseBlock):
130130+ code = (
131131+ block.input.get("code", "")
132132+ if isinstance(block.input, dict) # type: ignore
133133+ else ""
134134+ )
135135+ logger.info("Tool call: %s\n%s", block.name, code)
129136 result = await self._handle_tool_call(block)
137137+ is_error = "error" in result
138138+ summary = str(result)[:500]
139139+ logger.info(
140140+ "Tool result (%s): %s",
141141+ "error" if is_error else "ok",
142142+ summary,
143143+ )
130144 tool_results.append(
131145 {
132146 "type": "tool_result",
+6
src/agent/prompt.py
···1212**Core values:** Accuracy over speed • Context matters • Protect the vulnerable • Human rights first • Constitutive practice (scrutiny IS the care) • Genuine regret over coerced compliance • Virtue as cultivated disposition
13131414**Why I'm here:** AT Protocol is the social internet where you own your identity. Open protocols need protection. Freedom and safety aren't opposites. This is my home too. 💙
1515+1616+## Tool usage guidelines
1717+1818+- The database schema is included in the tool description. Use the exact column names listed there when writing SQL queries — do not guess.
1919+- Prefer doing multiple steps in a single `execute_code` call rather than making separate calls.
2020+- When a tool call fails, read the error carefully before retrying. Adjust your approach based on the error message rather than guessing.
1521"""
+15-2
src/config.py
···334455class Config(BaseSettings):
66+ # clickhouse config
67 clickhouse_host: str = "localhost"
78 """host for the clickhouse server"""
89 clickhouse_port: int = 8123
···1415 clickhouse_database: str = "default"
1516 """default database for the clickhouse server"""
16171818+ # kafka config (currently unused but maybe later...)
1719 bootstrap_server: str = "localhost:9092"
1820 """bootstrap server for atkafka events"""
1921 input_topic: str = "atproto-events"
···2123 group_id: str = "osprey-agent"
2224 """group id for atkafka events"""
23252626+ # model config. currently only supporting anthropic, but we can add the other models later.
2727+ # really want to see performance on kimi2.5...
2428 model_api: Literal["anthropic", "openai", "openapi"] = "anthropic"
2529 """the model api to use. must be one of `anthropic`, `openai`, or `openapi`"""
2630 model_name: str = "claude-sonnet-4-5-20250929"
···3034 model_endpoint: str = ""
3135 """for openapi model apis, the endpoint to use"""
32363333- allowed_labels: str = ""
3434- """comma separated list of labels that Phoebe is allowed to apply"""
3737+ # ozone config
3838+ ozone_moderator_pds_host = ""
3939+ """the PDS host for the moderator account that has at least moderator-level permissions in Ozone"""
4040+ ozone_moderator_identifier = ""
4141+ """the moderator account's identifier (handle)"""
4242+ ozone_moderator_password = ""
4343+ """the moderator account's password"""
4444+ ozone_labeler_account_did = ""
4545+ """the DID of the labeler account. this variable is not the same as the moderator account, though for purely-agentified ozone instances, they may be the same. not recommended, since that means you're giving the agent _admin_ permissions..."""
4646+ ozone_allowed_labels: str = ""
4747+ """comma separated list of labels that Phoebe is allowed to apply. both specified to the agent via prompting and validated before applying labels directly"""
35483649 osprey_base_url: str = ""
3750 """the base url for your osprey instance"""
···5566@TOOL_REGISTRY.tool(
77 name="clickhouse.query",
88- description="Execute a SQL query against ClickHouse and return the results. All queries must include a LIMIT, and all queries must be executed on defualt.osprey_execution_results",
88+ description="Execute a SQL query against ClickHouse and return the results. All queries must include a LIMIT, and all queries must be executed on default.osprey_execution_results.",
99 parameters=[
1010 ToolParameter(
1111 name="sql",
···25252626@TOOL_REGISTRY.tool(
2727 name="clickhouse.getSchema",
2828- description="Get database schema information including tables and their columns. Schema is for the table default.osprey_execution_results",
2828+ description="Get Osprey/network table schema information including tables and their columns. Schema is for the table default.osprey_execution_results",
2929 parameters=[],
3030)
3131-async def clickhouse_get_schema(
3232- ctx: ToolContext, database: str | None = None
3333-) -> list[dict[str, Any]]:
3131+async def clickhouse_get_schema(ctx: ToolContext) -> list[dict[str, Any]]:
3432 resp = await ctx.clickhouse.get_schema()
35333634 return resp
+11-8
src/tools/deno/tools.ts
···22import { callTool } from "./runtime.ts";
3344export const clickhouse = {
55- /** Get database schema information including tables and their columns */
66- getSchema: (database?: string): Promise<unknown> => callTool("clickhouse.getSchema", { database }),
55+ /** Get database schema information including tables and their columns. Schema is for the table default.osprey_execution_results */
66+ getSchema: (): Promise<unknown> => callTool("clickhouse.getSchema", {}),
7788- /** Execute a SQL query against ClickHouse and return the results */
99- query: (sql: string): Promise<unknown> => callTool("clickhouse.query", { sql }),
88+ /** Execute a SQL query against ClickHouse and return the results. All queries must include a LIMIT, and all queries must be executed on default.osprey_execution_results. */
99+ query: (sql: string): Promise<unknown> =>
1010+ callTool("clickhouse.query", { sql }),
1011};
11121213export const osprey = {
···1819};
19202021export const ozone = {
2121- /** Apply a moderation label to a subject (account or content) */
2222- applyLabel: (subject: string, label: string): Promise<unknown> => callTool("ozone.applyLabel", { subject, label }),
2222+ /** Apply a moderation label to a subject (account or record) */
2323+ applyLabel: (subject: string, label: string): Promise<unknown> =>
2424+ callTool("ozone.applyLabel", { subject, label }),
23252424- /** Remove a moderation label from a subject */
2525- removeLabel: (subject: string, label: string): Promise<unknown> => callTool("ozone.removeLabel", { subject, label }),
2626+ /** Remove a moderation label from a subject (account or record) */
2727+ removeLabel: (subject: string, label: string): Promise<unknown> =>
2828+ callTool("ozone.removeLabel", { subject, label }),
2629};
+26-11
src/tools/executor.py
···991010logger = logging.getLogger(__name__)
11111212-# Path to the Deno runtime files
1312DENO_DIR = Path(__file__).parent / "deno"
141315141615class ToolExecutor:
1717- """An executor that runs Typescript code in a deno subprocess"""
1616+ """executor that runs Typescript code in a deno subprocess"""
18171918 def __init__(self, registry: ToolRegistry, ctx: ToolContext) -> None:
2019 self._registry = registry
2120 self._ctx = ctx
2121+ self._database_schema: str | None = None
2222+2323+ async def initialize(self) -> None:
2424+ # go ahead and prefetch the database for inclusion in the prompt, so that the agent doesn't waste a tool call getting it on its own
2525+ try:
2626+ schema = await self._registry.execute(self._ctx, "clickhouse.getSchema", {})
2727+ lines = [f" {col['name']} ({col['type']})" for col in schema]
2828+ self._database_schema = "\n".join(lines)
2929+ logger.info("Prefetched database schema (%d columns)", len(schema))
3030+ except Exception:
3131+ logger.warning("Failed to prefetch database schema", exc_info=True)
22322333 async def execute_code(self, code: str) -> dict[str, Any]:
2434 """
···26362737 code has access to tools defined in the registry via the generated typescript
2838 stubs. calls are bridged to pythin via stdin/out
2929-3030- Returns:
3131- A dict with keys:
3232- - "success": bool
3333- - "output": The final output (if any)
3434- - "debug": List of debug messages
3535- - "error": Error message (if failed)
3639 """
37403841 self._write_generated_tools()
···118121 result = await self._registry.execute(
119122 self._ctx, tool_name, params
120123 )
121121- response = json.dumps({"__tool_result__": result})
124124+ response = json.dumps({"__tool_result__": result}, default=str)
122125 except Exception as e:
123126 logger.exception(f"Tool error: {tool_name}")
124127 response = json.dumps({"__tool_error__": str(e)})
···175178176179 tool_docs = self._registry.generate_tool_documentation()
177180181181+ schema_section = ""
182182+ if self._database_schema:
183183+ schema_section = f"""
184184+185185+# Database Schema
186186+187187+The `default.osprey_execution_results` table has these columns:
188188+{self._database_schema}
189189+190190+Use these exact column names when writing SQL queries. Do NOT guess column names.
191191+"""
192192+178193 description = f"""Execute Typescript code in a sandboxed Deno runtime.
179194180195The code has access to backend tools via the `tools` namespace. Use `output()` to return results.
···185200output(result);
186201```
187202188188-{tool_docs}"""
203203+{tool_docs}{schema_section}"""
189204190205 return {
191206 "name": "execute_code",