Building a Gemini Client¶
This tutorial will guide you through building a Python application that fetches and interacts with Gemini resources using nauyaca as a library.
What We'll Build¶
By the end of this tutorial, you'll have created a simple but functional Gemini browser that can:
- Fetch resources from Gemini servers
- Handle all response types (success, redirects, input, errors)
- Parse Gemtext content and extract links
- Follow links interactively
- Manage certificate trust using TOFU
Prerequisites¶
- Python 3.10 or higher
- Basic understanding of async/await in Python
- nauyaca installed (
uv add nauyacaorpip install nauyaca)
Step 1: Your First Gemini Request¶
Let's start with the simplest possible Gemini client - fetching a single URL and displaying its content.
Create a new file called gemini_fetch.py:
import asyncio
from nauyaca.client import GeminiClient
async def main():
"""Fetch and display a Gemini resource."""
# Create a client using async context manager
async with GeminiClient() as client:
# Fetch a resource
response = await client.get("gemini://geminiprotocol.net/")
# Display the response
if response.is_success():
print(f"Content-Type: {response.meta}")
print(response.body)
else:
print(f"Error: {response.status} - {response.meta}")
if __name__ == "__main__":
asyncio.run(main())
Run this script:
What's happening here:
GeminiClient()creates a client with TOFU certificate validation enabled by default- The async context manager (
async with) ensures proper cleanup client.get()fetches the URL and automatically follows redirectsresponse.is_success()checks if we got a success response (status 2x)- For success responses,
response.metacontains the MIME type andresponse.bodycontains the content
Step 2: Handling All Response Types¶
Gemini has several response types. Let's handle them all properly:
import asyncio
from nauyaca.client import GeminiClient
async def fetch_and_display(url: str):
"""Fetch a URL and display the appropriate information."""
async with GeminiClient() as client:
response = await client.get(url)
# Success (2x) - show content
if response.is_success():
print(f"✓ Success ({response.status})")
print(f"Content-Type: {response.meta}")
print("\n" + "=" * 70)
print(response.body)
print("=" * 70)
# Redirect (3x) - show where it's redirecting
elif response.is_redirect():
print(f"↻ Redirect ({response.status})")
print(f"Location: {response.redirect_url}")
# Note: client.get() automatically follows redirects by default
# This code path only executes if you use follow_redirects=False
# Input required (1x) - show prompt
elif 10 <= response.status < 20:
print(f"? Input Required ({response.status})")
print(f"Prompt: {response.meta}")
if response.status == 11:
print("(This is sensitive input - like a password)")
# Temporary failure (4x) - can retry later
elif 40 <= response.status < 50:
print(f"⚠ Temporary Failure ({response.status})")
print(f"Message: {response.meta}")
if response.status == 44:
print("Rate limited - try again later")
# Permanent failure (5x) - don't retry
elif 50 <= response.status < 60:
print(f"✗ Permanent Failure ({response.status})")
print(f"Message: {response.meta}")
# Client certificate required (6x) - needs authentication
elif 60 <= response.status < 70:
print(f"🔒 Certificate Required ({response.status})")
print(f"Message: {response.meta}")
print("You need a client certificate to access this resource")
async def main():
"""Test different URLs to see different response types."""
urls = [
"gemini://geminiprotocol.net/", # Success
"gemini://geminiprotocol.net/docs/", # Another success
]
for url in urls:
print(f"\n{'='*70}")
print(f"Fetching: {url}")
print('='*70)
await fetch_and_display(url)
if __name__ == "__main__":
asyncio.run(main())
Why this matters:
- Success (2x): Normal content delivery - display it
- Redirect (3x): Resource moved - follow it (or inform the user)
- Input (1x): Server needs more information - prompt the user
- Temporary failures (4x): Transient errors - retry might work
- Permanent failures (5x): Don't retry - resource doesn't exist
- Certificate required (6x): Needs client authentication - special handling needed
Step 3: Parsing Gemtext Content¶
Gemtext is Gemini's native markup format. It's line-oriented, making it easy to parse. Let's extract links from Gemtext content:
import asyncio
import re
from nauyaca.client import GeminiClient
def parse_gemtext_links(body: str) -> list[tuple[str, str]]:
"""Parse links from Gemtext content.
Returns:
List of (url, description) tuples.
"""
links = []
for line in body.split('\n'):
# Link lines start with "=>" followed by whitespace, URL, and optional description
if line.startswith('=>'):
# Remove the "=>" prefix and strip whitespace
link_content = line[2:].strip()
# Split on whitespace to separate URL from description
parts = link_content.split(None, 1) # Split on first whitespace
if parts:
url = parts[0]
description = parts[1] if len(parts) > 1 else url
links.append((url, description))
return links
async def fetch_with_links(url: str):
"""Fetch a URL and display its content with extracted links."""
async with GeminiClient() as client:
response = await client.get(url)
if response.is_success():
print(f"Content from: {url}")
print("=" * 70)
# Display the content
print(response.body)
# Extract and display links
links = parse_gemtext_links(response.body)
if links:
print("\n" + "=" * 70)
print(f"Found {len(links)} links:")
print("=" * 70)
for i, (link_url, description) in enumerate(links, 1):
print(f"{i}. {description}")
print(f" → {link_url}")
return links
else:
print(f"Error {response.status}: {response.meta}")
return []
async def main():
"""Fetch a page and display its links."""
url = "gemini://geminiprotocol.net/"
await fetch_with_links(url)
if __name__ == "__main__":
asyncio.run(main())
Understanding Gemtext links:
- Links start with
=>at the beginning of a line - Format:
=> URL [optional description] - Examples:
=> gemini://example.com/(URL only, use URL as description)=> /about About this capsule(relative URL with description)=> https://example.com External link(can link to other protocols)
Step 4: Building an Interactive Browser¶
Now let's combine everything into an interactive browser that lets you navigate between pages:
import asyncio
from urllib.parse import urljoin
from nauyaca.client import GeminiClient
def parse_gemtext_links(body: str) -> list[tuple[str, str]]:
"""Parse links from Gemtext content."""
links = []
for line in body.split('\n'):
if line.startswith('=>'):
link_content = line[2:].strip()
parts = link_content.split(None, 1)
if parts:
url = parts[0]
description = parts[1] if len(parts) > 1 else url
links.append((url, description))
return links
async def browse_interactive():
"""Interactive Gemini browser."""
# Start with a default URL
current_url = "gemini://geminiprotocol.net/"
async with GeminiClient() as client:
while True:
print("\n" + "=" * 70)
print(f"Fetching: {current_url}")
print("=" * 70 + "\n")
try:
response = await client.get(current_url)
if response.is_success():
# Display content
print(response.body)
print("\n" + "-" * 70)
# Extract links
links = parse_gemtext_links(response.body)
if links:
print(f"\nFound {len(links)} links:")
for i, (url, description) in enumerate(links, 1):
print(f"{i}. {description}")
# Prompt user to select a link
print("\nCommands:")
print(" [number] - Follow that link")
print(" b - Go back")
print(" u [url] - Visit a URL")
print(" q - Quit")
choice = input("\n> ").strip().lower()
if choice == 'q':
print("Goodbye!")
break
elif choice == 'b':
print("(Back navigation not implemented in this example)")
continue
elif choice.startswith('u '):
# Visit custom URL
current_url = choice[2:].strip()
elif choice.isdigit():
link_num = int(choice)
if 1 <= link_num <= len(links):
new_url = links[link_num - 1][0]
# Handle relative URLs
current_url = urljoin(current_url, new_url)
else:
print(f"Invalid link number: {link_num}")
input("Press Enter to continue...")
else:
print(f"Unknown command: {choice}")
input("Press Enter to continue...")
else:
print("\nNo links found on this page.")
input("Press Enter to continue...")
elif response.is_redirect():
# This shouldn't happen with default settings (auto-follow enabled)
print(f"Redirected to: {response.redirect_url}")
current_url = response.redirect_url
elif 10 <= response.status < 20:
# Input required
user_input = input(f"{response.meta}: ")
# In a real implementation, you'd need to handle query strings
# For now, just notify the user
print("Input handling not fully implemented in this example")
input("Press Enter to continue...")
else:
# Error
print(f"Error {response.status}: {response.meta}")
input("Press Enter to continue...")
except TimeoutError:
print("Request timed out")
input("Press Enter to continue...")
except ConnectionError as e:
print(f"Connection failed: {e}")
input("Press Enter to continue...")
except Exception as e:
print(f"Unexpected error: {e}")
input("Press Enter to continue...")
async def main():
"""Run the interactive browser."""
print("Welcome to the Gemini Browser!")
print("=" * 70)
await browse_interactive()
if __name__ == "__main__":
asyncio.run(main())
Try it out:
- Run the script
- Read the content
- Enter a link number to follow it
- Use
u gemini://example.com/to visit a specific URL - Enter
qto quit
Step 5: Configuring Client Options¶
The GeminiClient accepts several configuration options:
import asyncio
from pathlib import Path
from nauyaca.client import GeminiClient
async def main():
"""Demonstrate various client configuration options."""
# Example 1: Custom timeout
async with GeminiClient(timeout=60.0) as client:
response = await client.get("gemini://slow-server.example/")
# Example 2: Limit redirects
async with GeminiClient(max_redirects=3) as client:
response = await client.get("gemini://example.com/")
# Example 3: Disable redirect following
async with GeminiClient() as client:
response = await client.get(
"gemini://example.com/",
follow_redirects=False
)
if response.is_redirect():
print(f"Got redirect to: {response.redirect_url}")
# Example 4: Use client certificate for authentication
async with GeminiClient(
client_cert=Path("./client_cert.pem"),
client_key=Path("./client_key.pem")
) as client:
response = await client.get("gemini://auth-required.example/")
# Example 5: Custom TOFU database location
async with GeminiClient(
tofu_db_path=Path("./my_tofu.db")
) as client:
response = await client.get("gemini://example.com/")
# Example 6: Disable TOFU (not recommended for production!)
async with GeminiClient(trust_on_first_use=False) as client:
# This accepts any certificate without validation
response = await client.get("gemini://example.com/")
if __name__ == "__main__":
asyncio.run(main())
Configuration options:
timeout: Request timeout in seconds (default: 30.0)max_redirects: Maximum redirects to follow (default: 5)trust_on_first_use: Enable TOFU validation (default: True, recommended)tofu_db_path: Path to TOFU database (default:~/.nauyaca/tofu.db)client_cert/client_key: Client certificate for authenticationverify_ssl: Use CA validation instead of TOFU (default: False, not recommended for Gemini)
Step 6: Handling TOFU Programmatically¶
The client automatically handles TOFU validation, but you may want to handle certificate changes yourself:
import asyncio
from nauyaca.client import GeminiClient
from nauyaca.security.tofu import CertificateChangedError
async def fetch_with_tofu_handling(url: str):
"""Fetch a URL with manual TOFU certificate handling."""
async with GeminiClient() as client:
try:
response = await client.get(url)
print(f"Success: {response.status}")
except CertificateChangedError as e:
# Certificate changed - this could be legitimate or an attack
print(f"⚠ Certificate changed for {e.hostname}:{e.port}")
print(f"Old fingerprint: {e.old_fingerprint}")
print(f"New fingerprint: {e.new_fingerprint}")
# Ask user what to do
print("\nThis could mean:")
print(" 1. The server renewed their certificate (legitimate)")
print(" 2. Man-in-the-middle attack (security threat)")
choice = input("\nTrust new certificate? [y/N]: ").strip().lower()
if choice == 'y':
# Access the TOFU database and update trust
from nauyaca.security.tofu import TOFUDatabase
from nauyaca.security.certificates import load_certificate
# In a real implementation, you'd need to get the new certificate
# from the failed connection. For this example, we'll re-connect
# with TOFU disabled to get it.
# Create a new client with TOFU disabled
async with GeminiClient(trust_on_first_use=False) as temp_client:
response = await temp_client.get(url)
print("Certificate trusted for future connections")
else:
print("Certificate not trusted - connection aborted")
async def main():
"""Test TOFU handling."""
# This example won't actually trigger CertificateChangedError unless
# you've previously connected to a host that has changed certificates
await fetch_with_tofu_handling("gemini://geminiprotocol.net/")
if __name__ == "__main__":
asyncio.run(main())
Understanding TOFU:
- First connection: Certificate is automatically trusted and stored
- Subsequent connections: Certificate must match the stored fingerprint
- Certificate change: Raises
CertificateChangedError- you decide whether to trust it
Managing the TOFU database:
from pathlib import Path
from nauyaca.security.tofu import TOFUDatabase
# Open the database
tofu = TOFUDatabase() # Uses default path ~/.nauyaca/tofu.db
# List all known hosts
hosts = tofu.list_hosts()
for host in hosts:
print(f"{host['hostname']}:{host['port']} - {host['fingerprint']}")
# Revoke trust for a host
tofu.revoke("suspicious.example", 1965)
# Export to TOML for backup
export_data = tofu.export_to_toml()
Path("backup.toml").write_text(export_data)
# Import from TOML
toml_data = Path("backup.toml").read_text()
tofu.import_from_toml(toml_data)
Step 7: Complete Example - Gemini Fetch Tool¶
Here's a complete, production-ready script that combines everything we've learned:
#!/usr/bin/env python3
"""
gemfetch - A simple Gemini protocol fetcher
Usage:
python gemfetch.py <url>
python gemfetch.py --verbose <url>
python gemfetch.py --timeout 60 <url>
"""
import argparse
import asyncio
import sys
from nauyaca.client import GeminiClient
from nauyaca.security.tofu import CertificateChangedError
def parse_gemtext_links(body: str) -> list[tuple[str, str]]:
"""Parse links from Gemtext content."""
links = []
for line in body.split('\n'):
if line.startswith('=>'):
link_content = line[2:].strip()
parts = link_content.split(None, 1)
if parts:
url = parts[0]
description = parts[1] if len(parts) > 1 else url
links.append((url, description))
return links
async def fetch(url: str, verbose: bool = False, timeout: float = 30.0):
"""Fetch a Gemini URL and display results."""
async with GeminiClient(timeout=timeout) as client:
if verbose:
print(f"Connecting to: {url}", file=sys.stderr)
try:
response = await client.get(url)
# Show headers in verbose mode
if verbose:
print(f"\nStatus: {response.status}", file=sys.stderr)
print(f"Meta: {response.meta}", file=sys.stderr)
print(f"Type: {response.mime_type}\n", file=sys.stderr)
# Handle different response types
if response.is_success():
# Success - display body
print(response.body)
# Show links in verbose mode
if verbose:
links = parse_gemtext_links(response.body)
if links:
print(f"\n--- Found {len(links)} links ---", file=sys.stderr)
for i, (link_url, desc) in enumerate(links, 1):
print(f"{i}. {desc} -> {link_url}", file=sys.stderr)
return 0 # Success exit code
elif response.is_redirect():
print(f"Redirected to: {response.redirect_url}", file=sys.stderr)
return 0
elif 10 <= response.status < 20:
# Input required
print(f"Input required: {response.meta}", file=sys.stderr)
sensitive = " (sensitive)" if response.status == 11 else ""
print(f"The server is asking for input{sensitive}", file=sys.stderr)
return 1
elif 40 <= response.status < 50:
# Temporary failure
print(f"Temporary failure ({response.status}): {response.meta}",
file=sys.stderr)
if response.status == 44:
print("Rate limited - try again later", file=sys.stderr)
return 2
elif 50 <= response.status < 60:
# Permanent failure
print(f"Permanent failure ({response.status}): {response.meta}",
file=sys.stderr)
return 3
elif 60 <= response.status < 70:
# Certificate required
print(f"Client certificate required ({response.status}): {response.meta}",
file=sys.stderr)
print("Use --cert and --key options to provide a client certificate",
file=sys.stderr)
return 4
except CertificateChangedError as e:
print(f"Certificate changed for {e.hostname}:{e.port}", file=sys.stderr)
print(f"Old: {e.old_fingerprint}", file=sys.stderr)
print(f"New: {e.new_fingerprint}", file=sys.stderr)
print("\nThis could be a security threat!", file=sys.stderr)
print("Use 'nauyaca tofu revoke' then 'nauyaca tofu trust' if you trust the new certificate",
file=sys.stderr)
return 5
except TimeoutError:
print(f"Connection timed out after {timeout} seconds", file=sys.stderr)
return 6
except ConnectionError as e:
print(f"Connection failed: {e}", file=sys.stderr)
return 7
except Exception as e:
print(f"Unexpected error: {e}", file=sys.stderr)
if verbose:
import traceback
traceback.print_exc()
return 8
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Fetch and display Gemini protocol resources"
)
parser.add_argument("url", help="Gemini URL to fetch")
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Show verbose output including headers and links"
)
parser.add_argument(
"-t", "--timeout",
type=float,
default=30.0,
help="Request timeout in seconds (default: 30)"
)
args = parser.parse_args()
# Validate URL
if not args.url.startswith("gemini://"):
print("Error: URL must start with gemini://", file=sys.stderr)
sys.exit(1)
# Run the async fetch
exit_code = asyncio.run(fetch(args.url, args.verbose, args.timeout))
sys.exit(exit_code)
if __name__ == "__main__":
main()
Using the complete example:
# Basic usage
python gemfetch.py gemini://geminiprotocol.net/
# Verbose output
python gemfetch.py --verbose gemini://geminiprotocol.net/
# Custom timeout
python gemfetch.py --timeout 60 gemini://slow-server.example/
Next Steps¶
Congratulations! You've built a functional Gemini client. Here are some ways to extend it:
Additional features to explore:
- History and bookmarks - Save visited URLs and favorites
- Link following with history - Implement back/forward navigation
- Download non-text content - Handle images, audio, etc.
- Search integration - Add support for input queries (status 1x)
- Client certificates - Generate and use client certs for authentication
- Caching - Store responses to reduce redundant requests
- Multiple protocols - Handle http/https links found in gemtext
Further reading:
- Client API Reference - Complete API documentation
- TOFU How-To Guide - Advanced TOFU management
- Security API Reference - Security considerations
- Gemini Protocol Specification - Official spec
Common Issues and Solutions¶
Certificate Validation Errors¶
Problem: CertificateChangedError on every connection
Solution: The TOFU database might be corrupted. Clear it and start fresh:
Timeout Errors¶
Problem: Frequent timeout errors
Solution: Increase the timeout or check your network connection:
Relative URL Handling¶
Problem: Relative links don't work
Solution: Use urllib.parse.urljoin() to resolve relative URLs:
from urllib.parse import urljoin
base_url = "gemini://example.com/page"
relative_url = "/other"
absolute_url = urljoin(base_url, relative_url)
# Result: "gemini://example.com/other"
Unicode Content Issues¶
Problem: Garbled text or encoding errors
Solution: The client automatically uses UTF-8 (Gemini's default). If you encounter issues, check the charset parameter in the response:
if response.is_success():
charset = response.charset # Default: utf-8
print(f"Using charset: {charset}")
Summary¶
In this tutorial, you learned how to:
- ✅ Create a
GeminiClientand fetch resources - ✅ Handle all Gemini response types (success, redirect, input, errors)
- ✅ Parse Gemtext content and extract links
- ✅ Build an interactive browser with link following
- ✅ Configure client options (timeouts, redirects, certificates)
- ✅ Handle TOFU certificate validation programmatically
- ✅ Build a complete command-line fetch tool
You now have the foundation to build more sophisticated Gemini applications using nauyaca!