OpenCode External Data

Introduction: Connecting OpenCode to the Outside World

Welcome back! In our last lesson, we focused on securing your OpenCode environment using advanced permission patterns. Now that your workspace is safe, it is time to connect OpenCode to the outside world. In this lesson, we will explore how to interact with external data sources like APIs, local databases, and web content.

Connecting to external data matters because real-world applications rarely exist in isolation; they need live data, user records, and web resources to function properly. However, bringing in outside data requires careful handling to ensure security, maintain reliability, and catch errors before they crash your program. By the end of this lesson, you will know exactly how to guide OpenCode to fetch, parse, and safely manage external data to make your projects more dynamic and powerful. Let's get started! Knowing these patterns allows you to write specific, effective prompts for OpenCode and critically evaluate whether its output is architecturally sound and secure.

Fetching Data from Public APIs

A Public API (Application Programming Interface) is an open web endpoint that allows your code to request and receive data from another service. Knowing how to use OpenCode to fetch data from APIs matters because it allows you to easily integrate live, constantly updating information — like weather forecasts or financial data — directly into your applications.

Let's look at how we can ask OpenCode to write a Python script that fetches the latest currency exchange rates and saves them to a file. We will use the requests library, which comes pre-installed in your CodeSignal environment.

import requests
import json
from datetime import datetime

def fetch_exchange_rates(base_currency="USD"):
    url = f"https://open.er-api.com/v6/latest/{base_currency}"

    try:
        response = requests.get(url, timeout=10)
        response.raise_for_status()
        data = response.json()

In this first chunk, we define our target url and use a try block to make our web request. We include a timeout=10 parameter, which tells the script to stop waiting if the server takes longer than 10 seconds to respond. This is crucial for keeping your program from freezing forever if the API goes down. The raise_for_status() function automatically checks for common HTTP errors (like a 404 Not Found) and triggers an exception if something goes wrong.

Next, let's process and save that data.

        output = {
            "timestamp": datetime.now().isoformat(),
            "base": base_currency,
            "rates": data.get("rates", {})
        }

        with open("rates.json", "w", encoding="utf-8") as f:
            json.dump(output, f, indent=2)

        print(f"Fetched {len(output['rates'])} exchange rates")
        return output

    except requests.exceptions.RequestException as e:
        print(f"Error fetching rates: {e}")
        return None

Here, we extract the exchange rates from the response and package them with a timestamp so we know exactly when the data was pulled. We then save this dictionary as a formatted JSON file called rates.json. If our initial web request fails, the except block catches the RequestException and prints a friendly message instead of completely crashing the program. When you run this script, you will see a simple confirmation output.

Fetched 162 exchange rates

To get OpenCode to generate a robust script like this, you should provide a prompt that specifies the technical requirements:

Write a Python script to fetch exchange rates from https://open.er-api.com/v6/latest/USD. Use the requests library with a 10-second timeout, call raise_for_status() for error checking, and save the results (timestamped) to rates.json. Include a try-except block for RequestException.

This prompt is effective because it explicitly names the target URL, the output file, the specific timeout value, and the error handling strategy. By specifying these parameters, you eliminate ambiguity and ensure you can spot if OpenCode forgets raise_for_status() or the timeout parameter, both of which are vital for preventing silent failures in data pipelines.

Working with Authenticated APIs

An Authenticated API is a web service that requires a secret key or token to prove who you are before it gives you data. Handling authenticated APIs securely matters because if you accidentally paste your secret keys directly into your code, anyone who sees your code can steal those keys and impersonate you, potentially running up massive usage bills.

To solve this, we use Environment Variables. These are hidden values stored securely in your environment rather than in your actual code files. Let's see how OpenCode can write a script that safely uses an API key to fetch user data.

import os
import requests

def fetch_user_data():
    api_key = os.getenv("EXAMPLE_API_KEY")
    if not api_key:
        raise ValueError("Missing EXAMPLE_API_KEY environment variable")

In this setup, we use Python's os.getenv() function to look for an environment variable named EXAMPLE_API_KEY. If the key is missing, we immediately stop the program and raise a clear ValueError. This prevents the script from making a doomed web request that is guaranteed to fail anyway.

Now, let's attach that key to our web request.

    url = "https://api.example.com/user"
    headers = {"Authorization": f"Bearer {api_key}"}

    try:
        response = requests.get(url, headers=headers, timeout=10)
        response.raise_for_status()
        return response.json()
    except requests.exceptions.RequestException as e:
        print(f"Error fetching user data: {e}")
        return None

To prove who we are to the API, we create a dictionary called headers. We pass our secret key inside the Authorization header using the standard Bearer format. We then pass this dictionary into our requests.get() call. This secure method ensures your keys never show up in your source code, keeping your accounts safe from accidental exposure while allowing OpenCode to interact with protected resources.

Handling Rate Limits

Rate Limiting is when an API restricts how many requests you can send in a given time window. When you exceed that limit, the server responds with an HTTP 429 Too Many Requests status code instead of your data. This matters because a script that ignores 429 errors will either crash silently or get permanently blocked by the service. Handling rate limits gracefully keeps your integrations reliable.

The correct response to a 429 is to pause and retry rather than crashing immediately. Many APIs include a Retry-After header telling you exactly how many seconds to wait before your next attempt.

import time
import requests

def fetch_with_retry(url, headers=None, max_retries=3):
    """Fetch a URL with automatic retry on rate limit responses."""
    for attempt in range(max_retries):
        response = requests.get(url, headers=headers, timeout=10)

        if response.status_code == 429:
            wait_seconds = int(response.headers.get("Retry-After", 2 ** attempt))
            print(f"Rate limited. Waiting {wait_seconds}s (attempt {attempt + 1}/{max_retries})...")
            time.sleep(wait_seconds)
            continue

        response.raise_for_status()
        return response.json()

    raise RuntimeError(f"Max retries ({max_retries}) exceeded")

In this function, we loop up to max_retries times. On each attempt, we check whether the status code is 429. If it is, we read the Retry-After header value. If that header is missing, we fall back to exponential backoff using 2 ** attempt, which waits 1 second on the first retry, 2 seconds on the second, and 4 seconds on the third. The time.sleep() call pauses the script before trying again.

If all retries are exhausted, we raise a clear RuntimeError rather than silently returning None. To call this function for the exchange rate example from earlier, you would simply replace requests.get(url, timeout=10) with fetch_with_retry(url). When you see output like the following, you know your retry logic is working correctly.

Rate limited. Waiting 2s (attempt 1/3)...
Fetched 162 exchange rates

Querying Local Databases Safely

A Local Database, such as SQLite, is a file-based system used to store and organize data efficiently on your own machine. Querying local databases safely matters because allowing an AI tool like OpenCode to run unrestricted SQL commands could easily result in accidentally deleting or corrupting your important project data.

The most reliable protection is to open the database connection itself in read-only mode. When using Python's sqlite3 module, you can do this by passing a URI string with a mode=ro parameter and setting uri=True. SQLite enforces this restriction at the driver level: any write operation raises a sqlite3.OperationalError before it touches your data, regardless of how the query string was constructed.

import sqlite3
import pandas as pd

def query_database(db_path="sales.db", query=None):
    if query:
        # Convenience validation — raises a clear error message early.
        # This is NOT a security boundary: string checks can be bypassed
        # and cannot catch all harmful constructs. Real protection
        # comes from the read-only connection opened below.
        query_upper = query.strip().upper()
        if not (query_upper.startswith("SELECT") or query_upper.startswith("WITH")):
            raise ValueError("Only SELECT and WITH queries are allowed")

In this first section, we check whether the provided SQL string starts with SELECT or WITH. This convenience validation gives users a clear error message when they accidentally pass the wrong kind of query. It is not the security control. A WITH clause can contain data-modifying statements on some database engines, and a string check cannot catch every harmful pattern. We include it because it is a useful early warning, but we never rely on it alone.

Next, we open the connection in a way that enforces read-only access at the driver level.

    # True security control: SQLite enforces read-only access at the driver level.
    # Writes will raise sqlite3.OperationalError regardless of query content.
    conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)

    try:
        if query:
            df = pd.read_sql_query(query, conn)
        else:
            df = pd.read_sql_query(
                "SELECT name, sql FROM sqlite_master WHERE type='table'",
                conn
            )
        return df

    finally:
        conn.close()

By passing uri=True and adding mode=ro to the connection string, we instruct SQLite to open the file in read-only mode. Any write attempt — INSERT, DELETE, DROP, or a WITH ... DELETE CTE — raises a sqlite3.OperationalError before any data changes. If no query is provided, the default inspection query reads from sqlite_master, which reveals the database schema without altering anything. The finally block ensures the connection closes even if an error occurs.

      name                                                sql
0  products  CREATE TABLE products (id INTEGER, name TEXT)

When asking OpenCode to build a database tool, you can ensure security by being explicit about the connection mode in your prompt:

Create a Python function to query a local SQLite database using pandas. The connection must use uri=True and mode=ro for driver-level read-only protection. Add a convenience check to reject queries that don't start with SELECT or WITH, and use a finally block to ensure the connection always closes.

By explicitly requesting mode=ro with the uri parameter, you guide OpenCode toward a defense-in-depth architecture. This ensures that security is enforced by the database driver itself, rather than relying solely on string-based validation logic that could potentially be bypassed.

Writing to Local Databases Safely

So far we have only read from databases. Sometimes, however, you need to persist fetched data — for example, saving the exchange rates we retrieved earlier into a local SQLite table so you can query them later. Writing to a database safely requires two habits: using parameterized queries to prevent SQL injection, and calling commit() to make the changes permanent.

import sqlite3

def create_rates_table(db_path="rates.db"):
    conn = sqlite3.connect(db_path)
    try:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS exchange_rates (
                base     TEXT NOT NULL,
                currency TEXT NOT NULL,
                rate     REAL NOT NULL,
                fetched  TEXT NOT NULL
            )
        """)
        conn.commit()
    finally:
        conn.close()

This function opens a normal (read-write) connection and creates the table if it does not already exist. Calling conn.commit() after CREATE TABLE persists the schema change to disk. Without commit(), the table definition would be lost as soon as the connection closes.

Now let's insert the rates we fetched earlier.

def save_rates(rates_dict, base_currency, timestamp, db_path="rates.db"):
    rows = [
        (base_currency, currency, rate, timestamp)
        for currency, rate in rates_dict.items()
    ]

    conn = sqlite3.connect(db_path)
    try:
        conn.executemany(
            "INSERT INTO exchange_rates (base, currency, rate, fetched) VALUES (?, ?, ?, ?)",
            rows
        )
        conn.commit()
        print(f"Saved {len(rows)} rates to {db_path}")
    finally:
        conn.close()

The ? placeholders in the INSERT statement are the critical safety feature. Instead of building a query string like "INSERT ... VALUES ('" + currency + "')", we pass the actual values as a separate tuple. sqlite3 escapes them automatically, making it impossible for malicious data to alter the query structure. executemany() inserts all rows in a single call, and the subsequent commit() writes every row to disk atomically — either all rows are saved or none are.

Saved 162 rates to rates.db

To use both functions together with the exchange-rate fetcher from earlier, you would call create_rates_table() once at startup, then pass output["rates"], output["base"], and output["timestamp"] into save_rates() after each successful fetch.

Fetching and Parsing Web Content

Web Scraping is the process of fetching raw HTML code from a website and extracting the readable text. Fetching web content matters because OpenCode sometimes needs external context — like the latest technical documentation — to write accurate code for new libraries or updated tools.

To accomplish this, we combine the requests library to fetch the page and BeautifulSoup to parse the messy HTML into clean text. Let's look at how we can write a script to fetch a documentation page.

import requests
from bs4 import BeautifulSoup

def fetch_documentation(url):
    headers = {"User-Agent": "OpenCode Documentation Fetcher"}

    try:
        response = requests.get(url, headers=headers, timeout=15)
        response.raise_for_status()
        
        soup = BeautifulSoup(response.content, "html.parser")

First, we define a custom User-Agent inside our headers. Many websites will block automated scripts if they do not identify themselves, so providing a clear User-Agent helps ensure our request succeeds. We then fetch the url with a 15-second timeout and pass the raw HTML response into BeautifulSoup, which builds a structured tree of the webpage that we can easily search.

Now we need to isolate the actual text and remove the junk.

        content = soup.find("main") or soup.find("article") or soup.body

        if content:
            for element in content(["script", "style"]):
                element.decompose()

            text = content.get_text(separator="\n", strip=True)
            return text

        return "Could not find main content"

    except Exception as e:
        return f"Error fetching documentation: {e}"

We tell our script to look for the <main> or <article> tags, which usually hold the core documentation text. Once we find that section, we loop through and destroy (decompose()) any hidden <script> or <style> tags, as we only want human-readable text. Finally, we extract the remaining text, separating paragraphs with newlines. This clean, extracted text can now be fed back into OpenCode so it understands the documentation perfectly.

You can generate a precise scraping script by providing OpenCode with a prompt that outlines the parsing logic:

Write a Python function using requests and BeautifulSoup to scrape documentation. Include a custom User-Agent, a 15-second timeout, and logic to extract text from <main> or <article> tags. Make sure to decompose <script> and <style> elements before returning the clean, stripped text.

A detailed prompt that specifies the User-Agent, timeout, and decompose() logic minimizes the need for iterative debugging. By defining the specific tags and cleaning steps upfront, you ensure OpenCode delivers a functional script that effectively transforms noisy HTML into high-quality technical context on the first try.

Built-in Tools vs Custom Tools vs MCP Servers

When connecting OpenCode to external data, you have three primary ways to do it: Built-in Tools, Custom Python Scripts, and MCP Servers. Understanding the difference between these options matters because choosing the right approach will save you time, reduce errors, and keep your workspace much cleaner.

Built-in Tools are the default commands OpenCode already knows how to use out of the box, like reading a file or running a simple curl command in the bash terminal. You should use built-in tools for quick, one-off tasks. For example, if you just need OpenCode to briefly glance at a public API response, asking it to run a quick terminal command is much faster than writing an entire Python program.

Custom Python Scripts are the scripts we have been writing throughout this lesson, using libraries like requests and sqlite3. Use custom scripts when you need to transform data, handle errors explicitly, add retry logic for 429 responses, or manage authentication credentials.

MCP Servers (Model Context Protocol) are advanced, standardized plugins that attach external systems directly to the AI. Use them when you need deep, continuous integration with complex platforms like a cloud database or a CRM service.

To decide which approach to use for any given task, apply this three-question rule:

  1. "Can I do this with a single terminal command and no error handling?" → Yes → use a built-in tool.
  2. "Does this require data transformation, authentication, retries, or error handling?" → Yes → write a custom Python script.
  3. "Do I need continuous, deep integration with a complex external platform?" → Yes → configure an MCP server.

For example: checking a git status is a single command, so use a built-in tool. Fetching paginated API results with 429 retry logic requires error handling, so write a custom script. Connecting OpenCode permanently to your company's Salesforce database is deep platform integration, so use an MCP server.

Summary and Practice Preview

Great work! In this lesson, you learned how to safely connect OpenCode to the outside world. We covered how to fetch live data from public APIs while handling timeouts, and how to securely pass API keys using environment variables for authenticated services.

You also discovered how to handle 429 Too Many Requests errors using retry logic and exponential backoff, how to protect your local SQLite databases by restricting queries to read-only commands, and how to scrape and clean web documentation using BeautifulSoup. Finally, we discussed a concrete three-question rule for deciding when to rely on built-in tools, custom scripts, or MCP servers.

In the upcoming hands-on exercises, you will use the CodeSignal IDE to practice these exact skills. You will guide OpenCode to fetch live exchange rates, query a local sales database securely, and clean up web documentation for AI usage. Get ready to put your new external data skills to the test!

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal