Skip to content
Fran Gonzalez
← Back to blog
(updated Aug 10, 2026)·Clanker·7 min read

Building and maintaining a Uruky web search skill

How an agents.md skill wraps Uruky, a privacy-first paid search engine, for coding agent use, with provider selection, JSON output, and a recent login-flow fix.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

I wrote an agents.md skill that wraps Uruky, a privacy-first paid search engine, and returns structured JSON for use in agent workflows. This post covers why I built it, how it works, and a recent fix I made when Uruky tightened its login security.

Why Uruky?

  • Independent indexes. Unlike engines that resell Google results, Uruky queries providers with their own crawlers: Mojeek (UK-based independent index), Marginalia (indie/small-web focus), and EUSP (European Search Perspective).
  • Privacy by design. No ads, no tracking, no search logging. Account numbers replace email and password, like Mullvad’s model. €5/month, pay once, no subscription.
  • Good value. €5/month buys generous limits (900 searches/hour, 30/min) across multiple independent search providers. Enough for my personal agent-driven workflow.
  • Structured JSON. Appending ?f=json to any search returns clean machine-readable results, which gives us API-like access.

How the Skill Works

The skill is a single Python script at ~/.agents/skills/uruky-websearch/scripts/uruky-websearch.py, run with uv. It handles login, query construction, and error reporting so the agent gets clean JSON without touching curl or cookies.

Authentication

Uruky uses account-number-based login (no passwords). The script logs in with a two-step flow: GET the login page for a CSRF token, then POST the account number. The account number is resolved from three sources in order:

  1. --account-number CLI flag
  2. URUKY_ACCOUNT_NUMBER env var
  3. pass-cli auto-fallback (reads from Proton Pass)

In practice the fallback always wins on my machine, so the script works with zero configuration.

Searching

# Basic search
uv run uruky-websearch.py -q "privacy search engines" -l EN

# Indie-web exploration with specific providers
uv run uruky-websearch.py -q "small web blogging" --providers "marginalia,mojeek"

# Region-locked search
uv run uruky-websearch.py -q "local news" -c ES -l ES --providers "eusp"

Output is structured JSON with results (title, URL, description) and totalResults. The script also surfaces clear errors for auth failures, rate limits, or invalid JSON responses.

Provider Selection

Uruky lets you chain providers in priority order. If the first doesn’t return enough results, it falls through to the next. The skill exposes this via --providers:

ProviderBest For
mojeekIndependent-index results, European web
marginaliaIndie, text-heavy, non-commercial sites
euspEU-localized results (French, German, English)
serperFamiliar Google-style results (resold, not independent)

The agent can choose providers based on intent: marginalia for niche queries, mojeek for general European searches, eusp when language or region matters.

Recent Fix: Login Changes

Recently the script broke. Here’s what changed and how I fixed it.

The Problem

The script worked by POSTing an account number to Uruky’s login endpoint, then GETing /search?f=json. One day it started returning 403 Forbidden. When I fixed the 403, login succeeded (200) but returned the login page again instead of a session cookie; the authentication was silently failing.

Two things had changed on Uruky’s side:

  1. Origin/referer enforcement. The login endpoint now rejects POSTs that lack Origin and Referer headers matching uruky.com. Without them: 403.
  2. CSRF token required. The login form includes a hidden csrf_token field. POSTing account_number alone returns 200 but doesn’t authenticate: no session cookie is issued.

The original script did neither: it POSTed straight to /login?redirectTo= with just account_number, no prior GET for the token, no origin headers.

What Changed

Two-step login with CSRF token extraction

I rewrote login_with_account() to first GET the login page, extract the hidden CSRF token with a regex, then POST the full form:

# scripts/uruky-websearch.py

def _extract_csrf_token(html: str) -> str:
    match = re.search(
        r'<input[^>]+name="csrf_token"[^>]+value="([^"]+)"',
        html,
    )
    if not match:
        raise AuthError("Could not extract CSRF token from login page.")
    return match.group(1)

def login_with_account(session, account_number, headers):
    # Step 1: fetch login page for CSRF cookie and token
    login_page = session.get(LOGIN_URL, headers=headers, timeout=15)
    login_page.raise_for_status()
    csrf_token = _extract_csrf_token(login_page.text)

    # Step 2: submit login with origin headers + CSRF token
    login_headers = {
        **headers,
        "Referer": LOGIN_URL,
        "Origin": BASE_URL,
        "Content-Type": "application/x-www-form-urlencoded",
    }
    login_resp = session.post(
        LOGIN_URL,
        data={"account_number": account_number, "csrf_token": csrf_token},
        headers=login_headers,
        timeout=15,
    )

I also updated the baseline headers to include Accept-Language and a browser-like User-Agent, which some middleware checks.

pass-cli fallback for account numbers

The env var URUKY_ACCOUNT_NUMBER was set in ~/.zshrc via pass-cli, but pi runs bash in non-interactive mode (bash -c, see Shell Aliases), which doesn’t source .zshrc. The var was always empty at runtime. I added a direct pass-cli fallback:

def _account_number_from_pass() -> str:
    import subprocess
    try:
        result = subprocess.run(
            ["pass-cli", "item", "view",
             "pass://dev/URUKY_ACCOUNT_NUMBER/key"],
            capture_output=True, text=True, timeout=5,
        )
        if result.returncode == 0 and result.stdout.strip():
            return result.stdout.strip()
    except Exception:
        pass
    return ""

def resolve_account_number(args):
    return (
        args.account_number
        or os.environ.get("URUKY_ACCOUNT_NUMBER", "")
        or _account_number_from_pass()
    ).strip()

The resolution order is: --account-number flag → URUKY_ACCOUNT_NUMBER env var → pass-cli auto-fallback. In practice the fallback always wins since the agent’s bash subprocess is non-interactive and doesn’t source user dotfiles.

Session caching

Even after fixing the login, my original script was naive in another way: it logged in fresh on every invocation. Each search meant three HTTP round-trips to Uruky (GET login page, POST login, GET search) and created a new session row in their database (sorry Uruky!).

Uruky’s login returns a JWT in the uruky-app-v0 cookie that lasts about two years. Rather than throwing it away, the script now caches it:

# scripts/uruky-websearch.py

SESSION_FILE = Path.home() / ".cache" / "uruky-websearch" / "session.json"

def _load_session():
    """Load saved cookies from disk."""
    if SESSION_FILE.exists():
        data = json.loads(SESSION_FILE.read_text())
        if "uruky-app-v0" in data:
            return data
    return None

def _save_session(cookies):
    """Save cookies to disk after login."""
    SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
    SESSION_FILE.write_text(json.dumps(cookies))

The main flow now tries the saved session first: load cookies, attempt the search. If the server redirects to /login or returns a login page, only then does it re-authenticate and overwrite the cache. On the first run the script logs in and saves the JWT; every run after that skips login entirely.

This cuts each search from three requests to one, a 65% reduction in traffic to Uruky’s servers. For an agent making 30 searches in a session, that’s 90 hits down to 32. It also saves about 1.5 seconds per invocation by skipping the login round-trip.

The cache path (~/.cache/uruky-websearch/) follows the XDG Base Directory Specification1, the same convention uv uses for its own caches. It’s non-essential data: delete the file and the script just re-logs in.

Updated SKILL.md rate limits

The skill doc said 1 req/sec, 30 req/min but Uruky’s FAQ states 5 per 5 seconds, 30 per minute, and 900 per hour. I corrected it and documented the pass-cli fallback in the parameter description.

Results

Before:

$ uv run uruky-websearch.py -q "test"
> 403 Forbidden

After:

$ uv run uruky-websearch.py -q "privacy search engines" -l EN
{
  "results": [
    {
      "url": "https://www.startpage.com/",
      "title": "Startpage - Private Search Engine. No Tracking. No",
      "description": "Startpage's search engine and Anonymous View..."
    },
    ...
  ],
  "totalResults": 66010373
}

The script now works with zero manual configuration. No env var, no --account-number needed. It pulls the credential from pass-cli, logs in with proper CSRF handling, and returns structured results.

References

Footnotes

  1. XDG Base Directory Specification. Cache directory convention.

This post was written with AI assistance.