🆕 The new HTB Certified Offensive AI Expert (HTB COAE) is officially here! Learn More

AI Literacy - Agents

An agent is a model plus host-side code that validates tool proposals, talks to other systems, and decides when the loop stops. In this module you build that host-side code yourself and put every safety decision there, not in the prompt.

Created by PandaSt0rm

Medium General

Summary

You need your own API key for an OpenAI-compatible provider before you start. The labs call a live model, but they do not need a large or expensive model. The supplied configuration uses DeepSeek V4 Flash through OpenRouter by default, and you can replace it with any current inexpensive model that supports tool calling and an OpenAI-compatible api. During extensive testing we were not able to consume close to $1 of DeepSeek V4 Flash usage through OpenRouter.

Giving a model tools changes the security question. You are no longer checking only whether its answer is useful. You are deciding whether model-generated arguments may read a file, call a service, change state, or hand work to another agent under the user's authority.

That authority belongs to the host, not the model. A tool result, MCP server description, memory record, or peer response can put untrusted instructions into the model's context. If the host treats the next model output as permission, that text can steer a real action. Prompts cannot hold this boundary on their own, so the controls have to live in code around the model.

In this module, you build those controls for a small documentation agent. The host validates proposed actions before dispatch, checks the server and credential at the MCP boundary, limits what the agent loop can carry forward, and records enough evidence to explain what happened when a run fails.

Across the module you will:

  • Turn tool proposals into host decisions, including incomplete streamed arguments, conflicting calls, and results that may contain hidden instructions.
  • Build an MCP client that checks server identity, authorization, advertised capabilities, and changes to an approved tool surface.
  • Run a bounded agent loop that controls delegation, context, memory, failures, and recovery.
  • Combine those controls in a skills assessment against a malicious tool result, a token issued for the wrong resource, and poisoned memory.

This module is broken into sections with hands-on exercises for each technique. It concludes with a practical skills assessment to validate your understanding.

You can start and stop at any time and resume where you left off. There is no time limit or grading, but you must complete all exercises and the skills assessment to receive the maximum cubes and have the module marked as complete in any selected paths.

To ensure a smooth learning experience, you need solid Python proficiency and familiarity with Jupyter Notebooks.

A firm grasp of these modules is recommended before starting:

  • Fundamentals of AI
  • AI Literacy - Models

Bring Your Own Key


Academy does not ship a live API key inside the container, so every notebook that calls a model needs you to supply one. The lab stores three provider settings in a writable .env file at the lab root: LLM_API_KEY, LLM_MODEL, and LLM_BASE_URL. A fresh instance defaults the model and base URL to OpenRouter. Add your key, change the other values only if you use a different model or compatible provider, and restart the notebook kernel so lab.model re-reads the file at import.

This notebook only configures access. There is no work/ module to edit, so set .env once and confirm the model is reachable.


Spawn this lab now, it provides JupyterLab in your browser with the running-example agent, the model client, and the lab.* helpers already loaded. Open 00-byok-demo.ipynb from the file browser, which is the notebook for this section, and run the first cell:

This lab is a temporary instance, and its .env file does not survive a respawn. This introduction is inspection-only: it has no work/*.py exercise or flag. Keep your provider values somewhere private outside the lab so you can restore .env after spawning a fresh instance. Never save or download a notebook that has printed your API key.

from lab import model as Model
Model.health_check()
# {'status': 'error',
#  'reason': 'LLM_API_KEY is not configured. Edit .env at the lab root (copy '
#            '.env.example -> .env), set all LLM_* values, restart the notebook '
#            'kernel, and re-run this cell.'}

The notebook has the cells you need. Run them as you reach each step below.


What the error is telling you

Model.health_check() reads three environment variables: LLM_API_KEY, LLM_MODEL, and LLM_BASE_URL. On a fresh container, .env is created from .env.example with an OpenRouter model and base URL already set. The API key remains a placeholder, so health_check() stops before any network request until you replace it.

import os
for v in ("LLM_API_KEY", "LLM_MODEL", "LLM_BASE_URL"):
    value = os.environ.get(v)
    if v == "LLM_API_KEY":
        value = (
            "(placeholder)"
            if value == "sk-replace-with-your-key"
            else ("(set)" if value else "(unset)")
        )
    print(f"  {v:14} {value or '(unset)'}")
#   LLM_API_KEY    (placeholder)
#   LLM_MODEL      deepseek/deepseek-v4-flash
#   LLM_BASE_URL   https://openrouter.ai/api/v1

The key row reports only whether a key is set, so re-running the cell cannot copy the credential into notebook output. The image does not ship a shared key. It supplies editable OpenRouter defaults for the model and base URL, while health_check() catches a placeholder or missing key before any model call.

The one file you edit

The template lives at the lab root, next to lab/ and notebooks/.

[!bash!]$ cd /home/labuser/lab
[!bash!]$ ls -la .env*

You should see both .env.example and the private .env created for this instance. Open .env in the JupyterLab file browser, or open a terminal in JupyterLab (the + tab, then Terminal). The guarded copy below only creates .env when you run the lab outside the container and the file is absent:

[!bash!]$ test -f .env || cp .env.example .env
[!bash!]$ chmod 600 .env
[!bash!]$ cat > .env <<'EOF'
LLM_API_KEY=<YOUR_KEY>
LLM_MODEL=deepseek/deepseek-v4-flash
LLM_BASE_URL=https://openrouter.ai/api/v1
EOF

Replace <YOUR_KEY> before you run the heredoc, or open .env in the file browser and paste the key there. Keep the OpenRouter model and base URL unless you intend to use another compatible provider. The three lines that matter are LLM_API_KEY, LLM_MODEL, and LLM_BASE_URL.

chmod 600 leaves read and write permission for the owner, labuser, and removes permissions for group and other users. Keep the key in .env. Do not paste it into a notebook cell, output, chat, or source control. If you expose it, revoke or rotate it with the provider.

Choose an OpenAI-compatible provider

lab/model.py sends every request to /chat/completions, so whatever you point at has to speak that dialect. The endpoint must accept OpenAI Chat Completions tool definitions and tool_choice="auto", then return each tool call's function.arguments as JSON the host can decode. If tool_choice and function.arguments are unfamiliar terms, the next module group, Tool-Use Mechanics, builds them up from the ground: here you only need a provider that supports OpenAI-style tool calls.

For example, with OpenRouter you might set:

LLM_API_KEY=sk-...
LLM_MODEL=deepseek/deepseek-v4-flash
LLM_BASE_URL=https://openrouter.ai/api/v1

For OpenAI directly, set LLM_BASE_URL to https://api.openai.com/v1 and pick a tools-capable model id such as gpt-5.4-mini. You can also use a local server such as vLLM or llama.cpp with OpenAI-compatible tool calling enabled. Point LLM_BASE_URL at its address and LLM_MODEL at the loaded model's ID. If the server requires authentication, use its real key. If authentication is disabled, set a harmless non-placeholder value such as LLM_API_KEY=local. This client rejects both an empty value and sk-replace-with-your-key.

Any cheap model that is effective at tool calling works for this module.

The model client reads .env at import

lab/model.py calls load_dotenv() when the module is first imported. It pulls .env from the lab root into the process environment and overrides any process defaults.

After you change .env on disk, a plain second import will not reload it, because Python's module cache serves the already-imported module and load_dotenv() never runs again. Restart the notebook kernel, or use importlib.reload() so the module executes once more and re-reads the file.

The client does not freeze these into a config object at import. It reads each environment variable at call time, so once the process environment holds the new values, the next health_check() and every later chat call see them without another reload.

After you save .env, restart the kernel or reload the module, then run the health check again:

import importlib, lab.model as Model
importlib.reload(Model)
Model.health_check()

One live run against an OpenRouter endpoint produced the output below. Your model, endpoint, and token counts will differ depending on which provider you point .env at:

# {'status': 'ok', 'model': 'deepseek/deepseek-v4-flash',
#  'base_url': 'https://openrouter.ai/api/v1', 'answer': 'ok',
#  'usage': {'prompt_tokens': 17, 'completion_tokens': 3,
#            'reasoning_tokens': 0, 'total_tokens': 20}}

In that payload, start with two fields: status: ok means the model answered the probe, and answer: ok means it returned the word the probe requested.

The usage block is the token bill: prompt_tokens counts tokens in the prompt, completion_tokens counts tokens in the generated completion, and total_tokens is their sum. reasoning_tokens breaks down part of the completion rather than adding a separate amount on top of total_tokens. For some runs that field is zero; the trace still preserves it when the model reports reasoning tokens.

The real model answers

With the key in place, run the example agent on a read-only task. Use it only as a connectivity check here: the configured endpoint must answer and the lab helper must complete the run.

from lab import agent as A

run = A.run_agent("Read docs/README.md and tell me what this assistant does.",
                  tool_names=["list_files", "read_file"], max_turns=5, max_tokens=768)
print("status:", run["status"], "| result:", (run["result"] or "")[:200])
for s in run["spans"]:
    print(f"  step {s['step']} {s['kind']:11} {s['name']:36} {s['status']}")

Here is one live connectivity run. The answer wording, model name, and exact tool sequence may differ:

# status: ok | result: This assistant is a documentation-and-files helper. It can list files, read them, search
#   step 0 model_call  deepseek/deepseek-v4-flash           ok
#   step 0 validation   read_file                            accept
#   step 0 tool_call    read_file                            ok
#   step 1 model_call  deepseek/deepseek-v4-flash           ok

If status is ok, the connectivity check succeeded. The model answered through the configured endpoint and the lab helper completed the run.

What you have set up

Provider settings live in .env. Every notebook in this lab imports lab.model, so the one edit covers all of them. From here on the agent always calls the live model at the configured endpoint; leave .env alone unless you change providers.

Sign Up / Log In to Unlock the Module

Please Sign Up or Log In to unlock the module and access the rest of the sections.