···8899This allows it to:
10101111-- **Rule Management** - Writes, validate, and deploys rules for Osprey
1212-- **Data Analysis** - Queries via Clickhouse to analyze what is happening on your network
1111+- **Rule Management** - Write, validate, and deploy rules for Osprey
1212+- **Data Analysis** - Query ClickHouse to analyze what is happening on your network
1313+- **Investigation** - Look up domains, IPs, URLs, and WHOIS records to investigate threats
1414+- **Content Detection** - Find similar posts to detect coordinated spam and templated abuse
1315- **Moderation** - Apply labels and take moderation actions via Ozone (not actually implemented yet...)
14161517## How It Works
···1719Phoebe uses a model API as its reasoning backer. The agent writes and executes Typescript code in a sandboxed Deno runtime to interact with its tools — querying event data, creating safety rules, and managing moderation actions.
18201921```
2020-┌──────────────────────────────────────┐
2121-│ Model API │
2222-├──────────────────────────────────────┤
2323-│ Tool Execution (Deno Sandbox) │
2424-├──────────┬───────────┬───────────────┤
2525-│ Osprey │ ClickHouse│ Ozone │
2626-│ (Rules) │ (Queries) │ (Moderation) │
2727-└──────────┴───────────┴───────────────┘
2222+┌─────────────────────────────────────────────────────────┐
2323+│ Model API │
2424+├─────────────────────────────────────────────────────────┤
2525+│ Tool Execution (Deno Sandbox) │
2626+├──────────┬───────────┬──────────────┬───────────────────┤
2727+│ Osprey │ ClickHouse│ Ozone │ Investigation │
2828+│ (Rules) │ (Queries) │ (Moderation) │ (Domain/IP/WHOIS) │
2929+└──────────┴───────────┴──────────────┴───────────────────┘
2830```
29313032#### Why not traditional tool calling?
···3739When executing code inside of Deno, Deno is ran with the bare minimum of permissions. For example, it cannot access the file system, the network (local or remote), or use NPM packages. Both execution time and memory limits are applied. All network requests are done in Python,
3840in code that _you_ write, not the agent.
39414242+| Limit | Value |
4343+|-------|-------|
4444+| Max code size | 50,000 characters |
4545+| Max tool calls per execution | 25 |
4646+| Max output size | 1 MB |
4747+| Execution timeout | 60 seconds |
4848+| V8 heap memory | 256 MB |
4949+5050+## Tools
5151+5252+Phoebe has access to the following tools, organized by namespace:
5353+5454+| Namespace | Tool | Description |
5555+|-----------|------|-------------|
5656+| `clickhouse` | `query(sql)` | Execute SQL queries against Clickhouse |
5757+| `clickhouse` | `getSchema()` | Get the table schema and column info |
5858+| `osprey` | `getConfig()` | Get available features, labels, rules, and actions |
5959+| `osprey` | `getUdfs()` | Get available UDFs for rule writing |
6060+| `osprey` | `listRuleFiles(directory?)` | List existing `.sml` rule files |
6161+| `osprey` | `readRuleFile(file_path)` | Read an existing rule file |
6262+| `osprey` | `saveRule(file_path, content)` | Save or create a rule file |
6363+| `osprey` | `validateRules()` | Validate the ruleset |
6464+| `content` | `similarity(text, threshold?, limit?)` | Find similar posts using n-gram distance |
6565+| `domain` | `checkDomain(domain)` | DNS lookups and HTTP status checks |
6666+| `ip` | `lookup(ip)` | GeoIP and ASN lookups |
6767+| `url` | `expand(url)` | Follow redirect chains and detect shorteners |
6868+| `whois` | `lookup(domain)` | Domain registration and WHOIS info |
6969+| `ozone` | `applyLabel(subject, label)` | Apply a moderation label (not yet implemented) |
7070+| `ozone` | `removeLabel(subject, label)` | Remove a moderation label (not yet implemented) |
7171+4072## Prerequisites
41734274- [Deno](https://deno.com/) runtime
···5890# Required
5991MODEL_API_KEY="sk-ant-api03-..."
6092MODEL_NAME="claude-sonnet-4-5-20250929"
9393+9494+# Optional - Model API backend (default: anthropic)
9595+# MODEL_API="anthropic" # or "openai", "openapi"
9696+# MODEL_ENDPOINT="" # required for openapi, ie https://api.moonshot.ai/v1/completions
61976298# Osprey
6399OSPREY_BASE_URL="http://localhost:5004"
···11+from urllib.parse import urljoin, urlparse
22+33+import httpx
44+from whois import Any
55+66+from src.tools.registry import TOOL_REGISTRY, ToolContext, ToolParameter
77+88+_KNOWN_SHORTENERS = {
99+ "bit.ly",
1010+ "tinyurl.com",
1111+ "t.co",
1212+ "goo.gl",
1313+ "ow.ly",
1414+ "is.gd",
1515+ "buff.ly",
1616+ "j.mp",
1717+ "rb.gy",
1818+ "shorturl.at",
1919+ "tiny.cc",
2020+ "bl.ink",
2121+ "short.io",
2222+ "cutt.ly",
2323+ "rebrand.ly",
2424+}
2525+2626+2727+@TOOL_REGISTRY.tool(
2828+ name="url.expand",
2929+ description="Follow a URL through its redirect chain (up to 10 hops), recording each hop's URL and HTTP status code. Flags known URL shorteners. Useful for investigating obfuscated or shortened links in spam/phishing content.",
3030+ parameters=[
3131+ ToolParameter(
3232+ name="url",
3333+ type="string",
3434+ description="The URL to expand and follow through redirects",
3535+ ),
3636+ ],
3737+)
3838+async def url_expand(ctx: ToolContext, url: str) -> dict[str, Any]:
3939+ hops: list[dict[str, Any]] = []
4040+ current_url = url
4141+ max_hops = 10
4242+ visited: set[str] = set()
4343+4444+ async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as client:
4545+ for i in range(max_hops):
4646+ if current_url in visited:
4747+ break
4848+ visited.add(current_url)
4949+5050+ try:
5151+ # try HEAD first to avoid downloading large bodies
5252+ try:
5353+ response = await client.head(current_url)
5454+ except httpx.HTTPError:
5555+ response = await client.get(
5656+ current_url,
5757+ headers={"Range": "bytes=0-0"},
5858+ )
5959+6060+ hop = {
6161+ "hop": i + 1,
6262+ "url": current_url,
6363+ "status_code": response.status_code,
6464+ }
6565+6666+ parsed = urlparse(current_url)
6767+ if parsed.hostname and parsed.hostname.lower() in _KNOWN_SHORTENERS:
6868+ hop["is_shortener"] = True
6969+7070+ hops.append(hop)
7171+7272+ if response.status_code in (301, 302, 303, 307, 308):
7373+ location = response.headers.get("Location")
7474+ if not location:
7575+ break
7676+ # handle relative redirect URLs
7777+ current_url = urljoin(current_url, location)
7878+ else:
7979+ break
8080+8181+ except Exception as e:
8282+ hops.append(
8383+ {
8484+ "hop": i + 1,
8585+ "url": current_url,
8686+ "error": str(e),
8787+ }
8888+ )
8989+ break
9090+9191+ final_url = hops[-1]["url"] if hops else url
9292+ parsed_input = urlparse(url)
9393+ is_shortener = (
9494+ parsed_input.hostname is not None
9595+ and parsed_input.hostname.lower() in _KNOWN_SHORTENERS
9696+ )
9797+9898+ return {
9999+ "success": True,
100100+ "input_url": url,
101101+ "final_url": final_url,
102102+ "is_shortener": is_shortener,
103103+ "total_hops": len(hops),
104104+ "hops": hops,
105105+ }
+79
src/tools/definitions/whois.py
···11+import asyncio
22+from datetime import datetime, timezone
33+from typing import Any
44+55+import whois
66+77+from src.tools.registry import TOOL_REGISTRY, ToolContext, ToolParameter
88+99+1010+def _normalize_date(value: Any) -> str | None:
1111+ """normalize python-whois date values, which can be a single datetime, a list, or None."""
1212+ if value is None:
1313+ return None
1414+ if isinstance(value, list):
1515+ value = value[0] if value else None
1616+ if isinstance(value, datetime):
1717+ return value.isoformat()
1818+ if isinstance(value, str):
1919+ return value
2020+ return str(value) if value else None
2121+2222+2323+@TOOL_REGISTRY.tool(
2424+ name="whois.lookup",
2525+ description="Look up WHOIS registration data for a domain. Returns registrar, creation/expiration dates, name servers, registrant info, and domain age in days. Domain age is a key T&S signal — newly registered domains are heavily used for spam and phishing.",
2626+ parameters=[
2727+ ToolParameter(
2828+ name="domain",
2929+ type="string",
3030+ description="The domain name to look up (e.g. example.com)",
3131+ ),
3232+ ],
3333+)
3434+async def whois_lookup(ctx: ToolContext, domain: str) -> dict[str, Any]:
3535+ try:
3636+ w = await asyncio.to_thread(whois.whois, domain)
3737+ except Exception as e:
3838+ return {"success": False, "domain": domain, "error": str(e)}
3939+4040+ creation_date = _normalize_date(w.creation_date)
4141+ expiration_date = _normalize_date(w.expiration_date)
4242+ updated_date = _normalize_date(w.updated_date)
4343+4444+ # compute domain age
4545+ domain_age_days: int | None = None
4646+ if creation_date:
4747+ try:
4848+ raw = w.creation_date
4949+ if isinstance(raw, list):
5050+ raw = raw[0]
5151+ if isinstance(raw, datetime):
5252+ delta = datetime.now(timezone.utc) - raw.replace(tzinfo=timezone.utc)
5353+ domain_age_days = delta.days
5454+ except Exception:
5555+ pass
5656+5757+ name_servers = w.name_servers
5858+ if isinstance(name_servers, set):
5959+ name_servers = sorted(name_servers)
6060+6161+ return {
6262+ "success": True,
6363+ "domain": domain,
6464+ "registrar": w.registrar,
6565+ "creation_date": creation_date,
6666+ "expiration_date": expiration_date,
6767+ "updated_date": updated_date,
6868+ "domain_age_days": domain_age_days,
6969+ "name_servers": name_servers,
7070+ "dnssec": w.dnssec if hasattr(w, "dnssec") else None,
7171+ "registrant": {
7272+ "name": w.name if hasattr(w, "name") else None,
7373+ "org": w.org if hasattr(w, "org") else None,
7474+ "country": w.country if hasattr(w, "country") else None,
7575+ "state": w.state if hasattr(w, "state") else None,
7676+ "city": w.city if hasattr(w, "city") else None,
7777+ "emails": w.emails if hasattr(w, "emails") else None,
7878+ },
7979+ }
+6-1
src/tools/deno/tools.ts
···55 /** Get Osprey/network table 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. All queries must include a LIMIT, and all queries must be executed on default.osprey_execution_results. */
99+ query: (sql: string): Promise<unknown> => callTool("clickhouse.query", { sql }),
1010+};
1111+1212+export const domain = {
813 /** Lookup A, AAAA, NS, MX, TXT, CNAME, and SOA for a given input domain */
99- query: (domain: string): Promise<unknown> => callTool("clickhouse.query", { domain }),
1414+ checkDomain: (domain: string): Promise<unknown> => callTool("domain.checkDomain", { domain }),
1015};
11161217export const osprey = {
+2-2
src/tools/registry.py
···106106 if len(params) == 1:
107107 param_names = {p.name for p in tool.parameters}
108108 val = next(iter(params.values()))
109109- if isinstance(val, dict) and set(val.keys()) <= param_names:
110110- params = val
109109+ if isinstance(val, dict) and set(val.keys()) <= param_names: # ignore: type
110110+ params = val # type: ignore
111111112112 return await tool.handler(ctx, **params)
113113