09 Aug 2026
Planet Python
Ed Crewe: From Routing Checks to Trajectory Testing: Evaluating an Agentic Chatbot
pre[class*="language-"] { border-radius: 6px; font-size: 14px; overflow-x: auto; }
Which Agentic Chatbot?
I have been working on a Python based AI test framework for a chatbot interface for my company's product, Postgres AI Hybrid Manager. The manager allows the setup of Postgres clusters across cloud or on-prem and attaching various AI tools such as Langflow. So a combination of more traditional Postgres backup, migration, telemetry and analytics features along with LLM workflows leveraging the data it holds.
The product already has a control plane UI for managing Postgres estates. It also has full help for the product, all Postgres versions, analytics, AI and add ons. The chatbot brings all these things together: ask a question, get the relevant help, or ask it to do something such as migrate a cluster, or evaluate telemetry that would otherwise require clicking through the UI.
That makes it a pretty handy interface, especially for the less technical. However it is not a simple to test and ensure good quality responses.
A normal deterministic API test is simple. Send a request, check the status code, check the JSON body, perhaps check the database state. An LLM-backed agent does not pass or fail so clearly. It can route to the wrong capability and still return fluent text. It can pick a plausible but wrong tool. It can miss half the task and still sound confident. It can complete the first turn of a conversation and lose the plot on the second. It could get malformed or missing data from tooling that leads it to deliver a misleading conclusion. It might only provide help to something that should be from tool data or was a request for an action such as create a cluster.
So the testing problem was not "does the chatbot return a reasonable response?" It was "how do we test the whole chat path is doing the right thing?"
This is the story of how our agent-eval test framework evolved as we worked to see that our chatbot was not only getting the right answer, 42 , but whether it was asking all the right questions of the right tools to get that answer. Known as trajectory testing ...
You're Golden
Before we can tell our story we need to define some terms.
A Golden is an example of a perfect desired output from a test input. They often refer to more complex outputs that may need saving as separate files, but a simple assertable output such as 42, is a golden too!
Whilst complex goldens may be used and marked for semantic similarity against the test output. It is more common for complex outputs to be described by a rubric. A rubric is a checklist of qualitative properties a good answer must exhibit, written in plain English as opposed to a golden example of an answer.
For AI testing the tests are termed evals, ie they evaluate the tool, but not by strict assertions, because one thing you can be sure of with an LLM is that given the same input, you usually get subtly different output, ie they are non-deterministic. Which means for LLM outputs the only way to test them is to use an LLM-as-judge, ie give that LLM the test output and a rubric or golden and let it mark it against that. Then you set a pass threshold for that mark, to translate your complex output into a pass or fail.
You can also total up all the passes to give you a Task Completion Rate, TCR. So with complex AI agentic LLM interactions a 100% pass of all evals is often not realistic. Hence you set a TCR below 100% for the whole test suite of evals to pass. Start with the smallest useful test. The core principle of evals is not complicated, you want the input to give you the expected output.
But for an Agentic application this may require a sequence of LLM calls and tools: Making the final output dependent on the route that should be chosen, the tool(s) that should be called, the actions to be taken, further LLM calls that may be necessary and finally the core data that the response to the user should contain.
Our first version did not try to solve every part of that. It started with routing, simple and deterministic.
Routing is the starting point
The chatbot originally had an agent per tool. The tool being the code and API calls that performed actions or returned data or help.
Different specialist agents owned different parts of the product surface: Control-plane actions, Postgres database operations, schema design, roles and permissions, cluster reporting, migration, and so on.
Before any specialist can help, something has to choose the right specialist.
So the first eval suite asked a narrow question:
Given this user prompt, did the chatbot route to the expected tool?
That gave us a fast health check. We could keep a corpus of prompts, map each one to an expected destination, run them through either a direct model path or the real proxy, and score whether the selected destination matched the golden.
A golden here is just the name of the tool:
- id: "core-iam-001"
prompt: "List all my projects"
expected_tool: "control-plane"
tags: ["core", "control-plane", "project"]
And the check on the other end is deliberately dumb - an equality test, not a semantic one:
self.success = tool_match(predicted_tool, expected_tool)
Agents became skills, but routing remained
The design moved away from "one agent per tool family" toward a more consolidated orchestrating agent with skills.
That is a better fit for how modern agent systems are evolving. A skill = instructions, constraints, and a subset of tools that are relevant for a task. It is a form of progressive disclosure. Give the model the minium it needs at each step to save tokens.
But this did not make routing irrelevant.
Instead of asking "did we transfer to the right sub-agent?", the eval asks "was the right skill made visible and selected for this task?" The labels changed but a skill could still use the wrong tool.
Routing evals stayed valuable because they were fast, explainable, and easy to run in CI. But they are limited, routing should always be correct but it doesn't mean that the final agent response is too.
TCR jumps to the endpoint, the response
Task Completion Rate, or TCR, was the next step.
The user asked for a cluster comparison, or a schema recommendation, or help diagnosing a database issue. We need to know whether the full response actually completed these tasks.
Responses are complex goldens so they need the LLM-as-a-judge pattern: run the chatbot, take the actual response, and ask a judge model to score it against expected sections.
The eval has a rubric here for judging the output:
- id: "tcr-core-014"
prompt: "Compare CPU usage between these two clusters"
expected_sections:
- "identifies which cluster has higher CPU usage"
- "cites at least one supporting metric"
- "suggests a plausible next step"
The judge gets one simple instruction: score each expected_sections between 0.0-1.0 A metric class then just thresholds it for pass / fail:
self.success = score >= 0.7
The judge must be calibrated and a consistent model used for comparing runs over time. Enabling skill an prompt tuning from metric trends. The rubric must be specific enough to avoid marking waffle as success. But it turns a non-deterministic complex output into a simple pass and fail. It also separated two different levels of QA:
- Can the underlying model answer the task if given the right context?
- Does the deployed chatbot complete the task through the real product path?
That led to two execution modes.
Direct mode calls the model with simulated context. It is faster and useful for prompt and rubric development.
Proxy mode calls the real chatbot. It is slower, but it exercises the production path: routing, skill selection, tool calls, guardrails, streaming responses, conversation state, and the actual service wiring.
Both matter. Direct mode tells you whether the model is capable of the answer. Proxy mode tells you whether your product is capable of delivering it.
Multi-step conversations changed the unit of testing
Single-turn TCR is still too small for many real chatbot tasks.
Users do not always provide all required information in one message. They ask to create a cluster, then pick a project, then choose a size, then confirm. They ask for a schema review, then refine the problem, then ask for a migration path. They troubleshoot by adding information over time.
So the framework has to exercise test cases that are conversations, not just single prompts.
That sounds like a minor data-model change. It was not. Once a test has steps, the eval runner has to preserve conversation state. In proxy mode, that means carrying the real conversation_id returned by the chatbot and sending each follow-up as part of the same server-side conversation. In direct mode, it means building a synthetic conversation history so the model sees the prior turns.
In code that split is about as literal as it sounds. Proxy mode threads a real id through each call:
response = client.send_message(prompt=msg, conversation_id=conversation_id)
conversation_id = response.conversation_id # captured on turn 1, reused after
Direct mode has no server-side conversation to lean on, so it fakes one by re-rendering the transcript into the prompt itself, every turn:
full_prompt = f"## Conversation History\n{render(history)}\n\n{next_prompt}"
Same test case, same expected outcome, but a different code path depending on which half of the system is actually holding the conversation state. That's impacts multi-turn evals because conversation memory is part of the harness code for the actual deployment not just a model issue.
The scoring also becomes more interesting. You want per-step checks, because the assistant should ask the right clarifying question at the right time. You also want an overall score, because a conversation can have reasonable individual turns and still fail to complete the user's goal.
Coding it yourself: deepeval underneath
Everything above sits on top of deepeval, the open-source LLM eval library. We add a Synthesize → Execute → Evaluate pipeline, a plugin system, YAML goldens, CI wiring, and Langfuse push on top of it But the core library underneath is plain deepeval, and you do not need any of the surrounding machinery we used. Here are routing, TCR and multi-step just built directly on deepeval (simplified deepeval 3.6.9)
A test case is just an input/output pair. LLMTestCase is the base unit everything else scores:
from deepeval.test_case import LLMTestCase
test_case = LLMTestCase(
input="List all my projects",
actual_output=chatbot_response_text, # what the system under test said
expected_output="control-plane", # the golden - a skill label here, not prose
additional_metadata={"predicted_skill": predicted_skill},
)
Routing is a custom metric, not a built-in one. deepeval ships plenty of semantic metrics, but "did it route to the right skill" is an exact-match business rule, so you write your own BaseMetric. This is a simplified version of the same shape our real AgentMatch metric takes:
from deepeval.metrics import BaseMetric
from deepeval.test_case import LLMTestCase
class AgentMatch(BaseMetric):
def __init__(self, threshold: float = 1.0):
self.threshold = threshold
self.async_mode = False # routing checks are cheap; no need for async here
def measure(self, test_case: LLMTestCase) -> float:
predicted = test_case.additional_metadata["predicted_skill"]
expected = test_case.expected_output
self.score = 1.0 if tool_match(predicted, expected) else 0.0
self.success = self.score >= self.threshold
return self.score
async def a_measure(self, test_case: LLMTestCase) -> float:
return self.measure(test_case)
def is_successful(self) -> bool:
return bool(self.success)
@property
def __name__(self):
return "Agent Match"
tool_match is the check from earlier. Run it with deepeval's own runner rather than hand-rolled assertions, and you get retries, pretty output, and a result object for free:
from deepeval import evaluate
evaluate(test_cases=[test_case], metrics=[AgentMatch()])
TCR is where deepeval's built-in GEval earns its keep. GEval is deepeval's off-the-shelf LLM-as-judge metric, you give it criteria (or explicit evaluation steps) and it handles the judge prompt, the JSON parsing, and the scoring for you. Our rubric-per-line expected_sections maps onto evaluation_steps almost directly:
from deepeval.metrics import GEval
from deepeval.test_case import LLMTestCase, LLMTestCaseParams
task_completion = GEval(
name="TaskCompletion",
evaluation_steps=[
"Check whether the response identifies which cluster has higher CPU usage",
"Check whether the response cites at least one supporting metric",
"Check whether the response suggests a plausible next step",
],
evaluation_params=[LLMTestCaseParams.INPUT, LLMTestCaseParams.ACTUAL_OUTPUT],
threshold=0.7,
)
test_case = LLMTestCase(
input="Compare CPU usage between these two clusters",
actual_output=chatbot_response_text,
)
evaluate(test_cases=[test_case], metrics=[task_completion])
Multi-step conversations get their own test case type. ConversationalTestCase takes a list of Turns instead of a single input/output pair, and pairs with a BaseConversationalMetric instead of BaseMetric:
from deepeval.test_case import ConversationalTestCase, Turn
convo = ConversationalTestCase(
turns=[
Turn(role="user", content="Create a new cluster"),
Turn(role="assistant", content="Sure - which project should it go in?"),
Turn(role="user", content="acme-prod"),
Turn(role="assistant", content=final_response_text),
],
expected_outcome="A cluster is created in acme-prod after resolving the missing project name",
)
deepeval has a conversational counterpart to GEval too (ConversationalGEval), scored against the whole turn sequence rather than a single response which is the natural fit for "did the assistant ask the right clarifying question at the right time", the per-step-plus-overall shape TCR needed once prompts became conversations.
Put together, that is the whole starting kit: LLMTestCase plus a hand-written BaseMetric for hard business rules like routing, GEval for rubric-style task completion, ConversationalTestCase plus ConversationalGEval once a prompt becomes a conversation, and evaluate() to run the lot and get a result object back.
Everything else we built, the YAML goldens, the plugin architecture, the CI wiring, the Langfuse push exists to run more of these at scale and make the failures easy to find. But none of it is required to get started. If you are testing your own agentic chatbot, this is how to begin.
This is where instrumentation started to matter much more.
For a single-turn answer, a markdown report with pass/fail rows is often enough to start debugging. For multi-step conversations, that is thin. You need to know which turn failed, whether the route changed, whether the wrong tool was called, whether the tool call used correct arguments, whether the model forgot earlier context, or whether the final answer simply missed a required section.
That is why we added span-level telemetry and pushed eval traces into Langfuse.
Langfuse made the failures inspectable
The useful thing about Langfuse is not just having another pretty dashboard. Although that is important for spotting quality regressions over time via regular CI/CD automated runs.
The vital thing was being able to treat an eval run as a set of traces. A run becomes a session. Each test case becomes a trace. The trace carries the prompt, response, scores, tags, model, mode, scenario, and the spans emitted by the proxy.
For a chatbot path, those spans are where the debugging starts. You can see routing, tool execution, LLM calls, latency, and token usage where it is available. You can filter by scenario and model. You can compare runs. You can look at a failing conversation and see whether the problem began at route selection, tool selection, tool arguments, or final synthesis.
That changes the tuning loop.
Without traces, an eval failure says "this case failed". With traces, it can say why it failed.
That distinction matters because the fix lands in different places...
Is it a routing rule?
Is it a skill description?
Is it a tool schema?
Is it the judge rubric?
Is it that the eval has has an expectation that the product has never actually promised?
Trajectory testing -> knitted the pieces together
Routing and TCR started as separate signals.
Routing asked whether the right capability was selected. TCR asked whether the final task was completed. Multi-step testing asked whether that held across a conversation. Instrumentation showed what happened between those points.
Trajectory testing is the next natural step: score the path itself.
For an agentic product, the fully correct path is essential to response quality.
So trajectory tests add expectations about intermediate actions:
- which tool or flow should be used
- whether the arguments are valid
- whether the conversation reached the right state
- whether the final answer completed the task
The label-based routing tests are still useful as fast canaries. They tell us whether the classifier shape has drifted and distinguish tiers - see the next section.
But full trajectory tests judge the route by consequence: did the system actually follow the tool path that would satisfy the user?
So retain the fast determisitc routing tests, but move more user-visible behavioural coverage into trajectory and TCR.
Sovereign AI makes the eval problem tiered S/M/L/XL
There is one more constraint that makes this more than a generic chatbot-testing story.
Our chatbot has to work for sovereign and air-gapped deployments. In those environments, prompts, tool results, schema details, and operational data cannot be sent to a hosted frontier model outside the customer's trust boundary. The inference model may run inside the customer's environment.
That usually means a smaller model.
Smaller models are not just cheaper versions of larger ones. They have different context limits, weaker tool-selection behaviour, and less tolerance for an over-wide capability surface. If you show a smaller model every possible tool and skill, you have increased the chance that it chooses a bad one.
So the architecture becomes tiered. Models are effectively T-shirt sized. A small self-hosted model sees a curated subset of reliable skills. A larger model can be allowed to see more. Some experimental or complex skills only make sense for the highest tiers.
That changes the meaning of a routing eval again.
The correct visible skill set is no longer universal. It depends on the model tier. A prompt that should route to an advanced skill for an XL model may need to be dropped, refused, or handled differently for a smaller model that should not see that skill at all.
This is why trajectory testing and routing need to be tier-aware. We are not only asking whether the chatbot can complete a task. We are asking whether it can complete the task through the capability surface that a deployment's LLM size allows.
What I would keep from the journey
The final shape was not obvious at the start.
We began with routing because it was the first integration failure point and the cheapest one to isolate. We added TCR because correct routing did not prove task completion. We added multi-step cases because real users have conversations, not isolated prompts. We added telemetry because multi-step failures are otherwise too hard to debug. We moved toward trajectory testing because the route, tools, arguments, and answer need to be judged as one path.
If I were starting another agentic product eval framework, I would keep that order.
Do not start by trying to build a grand universal benchmark. Start with the smallest failure point that would embarrass the product if it regressed. Then move the signal closer to the user's actual goal.
For a chatbot wired into a real control plane, that means testing more than the output text. It means testing the route, the skill, the tool call, the arguments, the conversation state, the final answer, and the model tier that made those options visible in the first place.
That is the difference between checking that an AI system said something vaguely relevant and checking that it actually did all the things the user asked of it.
09 Aug 2026 6:04am GMT
08 Aug 2026
Django community aggregator: Community blog posts
Django: introducing django-msgspec
It's another day, another new package day here. Say hello to django-msgspec, a package of drop-in replacements for Django and Django REST Framework (DRF) components backed by msgspec.
msgspec is a C-based serialization library covering JSON, MessagePack, YAML, and TOML, with optional schema validation through typed Struct classes. Its JSON encoder and decoder are several times faster than the standard library's, which makes django-msgspec a cheap performance win in the parts of Django that handle JSON.
Features
There's a version of JsonResponse:
from django_msgspec.http import JsonResponse
def index(request):
return JsonResponse({"title": "Hello, world!"})
…a test client with matching test case classes:
from django_msgspec.test import SimpleTestCase
class IndexTests(SimpleTestCase):
def test_index(self):
response = self.client.get("/", headers={"accept": "application/json"})
assert response.status_code == 200
# response.json() uses msgspec to parse the response body
assert response.json() == {"title": "Hello, world!"}
…a version of Django's json_script template tag, which is where this whole story began:
{% load django_msgspec %}
{{ sales_by_product_id|json_script:"chart-data" }}
…and a handful of components that you activate purely through settings, with no code changes at all:
SESSION_SERIALIZER = "django_msgspec.sessions.JSONSerializer"
SERIALIZATION_MODULES = {
"json": "django_msgspec.serializers.json",
"jsonl": "django_msgspec.serializers.jsonl",
}
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": ["django_msgspec.rest_framework.JSONRenderer"],
"DEFAULT_PARSER_CLASSES": ["django_msgspec.rest_framework.JSONParser"],
}
That covers session storage and signing, dumpdata / loaddata in both JSON and JSON Lines, and DRF request parsing and response rendering.
Everything encodes with an enc_hook that knows about Django's lazy strings, so translated text passes through as you'd expect. It's all tested against the currently supported versions of Python and Django, with 100% coverage.
Déjà vu?
Only three weeks ago, I introduced django-orjson, a near-identical package backed by the Rust-powered orjson. So yeah, you might be confused why I made another faster-alternative-JSON-package-wrapper package so soon after the first one.
After releasing django-orjson, several folks from the community reached out to me telling me about the issues with orjson and pointing to msgspec instead. Additionally, while trying to roll out django-orjson on a client project, I learned that certain documented behaviours and limitations in orjson were going to be road blockers.
Here's a full list of what I learned:
-
Dictionary keys have to be strings. Take a mapping of object ID to some count:
>>> import json >>> json.dumps({1: "one"}) '{"1": "one"}' >>> import orjson >>> orjson.dumps({1: "one"}) Traceback (most recent call last): ... TypeError: Dict key must be str
JSON objects can only have string keys, so the standard library coerces non-string keys with
str(). But orjson refuses to do so, with the justification that the coercion is lossy and the keys will come back as strings when deserialized. Thankfully orjson does have an option here,OPT_NON_STR_KEYS, that opts in to the standard library behaviour, but you gotta know about it! -
Integers are limited to 64 bits.
>>> json.dumps(2**64) '18446744073709551616' >>> orjson.dumps(2**64) Traceback (most recent call last): ... TypeError: Integer exceeds 64-bit range
JSON itself sets no limit on number sizes, but RFC 8259 warns that implementations may, and many do-JavaScript, for one, silently loses precision beyond 253. orjson caps integers at 64 bits, matching native integer types, while Python's arbitrary-precision integers mean the standard library will happily emit larger values.
In this case, orjson is probably more correct, but it is lacking an option to restore compatibility if required. (I didn't encounter any use case for massive numbers myself.)
-
There's nowhere to report problems.
The orjson README sayeth:
There is no open issue tracker or pull requests due to signal-to-noise ratio.
I have plenty of sympathy for that decision, having felt the weight of my own project's issue trackers backing up. But it does mean that when you hit any problems, you can't check whether it's known, follow a fix, or contribute one. Your options are to read the CHANGELOG and hope, or to work around it yourself.
-
No sub-interpreter support, ever.
The README also says:
orjson does not and will not support PyPy, embedded Python builds for Android/iOS, or PEP 554 subinterpreters.
I kinda missed this one when adopting orjson, but for me it's a bit of a concern. I think subinterpreters could support some cool use cases, like fast parallel test runners, and from my experience writing extension packages, support for them does not add much overhead.
And this is not a soft limitation-you can't even import orjson in a sub-interpreter, let alone serialize anything:
>>> from concurrent import interpreters >>> interp = interpreters.create() >>> interp.exec("import orjson") Traceback (most recent call last): ... concurrent.interpreters.ExecutionFailed: ImportError: module orjson.orjson does not support loading in subinterpreters
It's a shame that the orjson maintainer advertises such a hard line here.
-
No free-threading support yet.
orjson currently publishes no wheels for free-threaded Python, which is now "stable" as of Python 3.14. On a free-threaded build, you're forced to compile the Rust package from source, which is a bit of an adoption blocker for large teams.
-
Pydantic decided against it, on trust grounds.
Back in 2019, a contributor opened a pull request to use orjson in Pydantic, and Samuel Colvin declined it. His stated reasons were:
- orjson's author gives no name or personal details on GitHub.
- The author had privately emailed Sam asking for the integration, and he was "surprised and somewhat worried by the hostile response I got when I made his/her request public".
- The compiled wheels for the package could potentially contain malicious code, which seems like more of a risk given the author's anonymity.
He was careful to add: "Let me make it clear: I'm not accusing <the maintainer> of anything, I'm 99% certain that his/her intentions are honourable."
I have the same feelings, now that I'm aware of all the (public) details of orjson. Pseudonymity is entirely legitimate and plenty of excellent software is written under a handle, and this was seven years ago. But stack it up with the closed issue tracker, and it does mean that installing orjson means trusting a compiled binary from a maintainer who has chosen not to engage in public at all.
msgspec's advantages
msgspec answers the questions raised by the above points against orjson:
-
It handles numerical keys the way the standard library does:
>>> import msgspec.json >>> msgspec.json.encode({1: "one"}) b'{"1":"one"}'
-
It encodes and decodes large integers:
>>> import msgspec.json >>> msgspec.json.encode(2**64) b'18446744073709551616' >>> msgspec.json.decode(b"18446744073709551616") 18446744073709551616
-
It has an open issue tracker.
-
msgspec fails to import in sub-interpreters right now, but the issue is being worked on by the maintainer and contributors.
-
msgspec publishes free-threading compatible wheels today.
-
The creator is not anonymous and the project is now maintained by a group in a GitHub organization, featuring at least Nikita Sobolev who I have known online from other open source projects (especially django-stubs).
And most importantly, msgspec's encoding and decoding are in the same performance ballpark as orjson's!
msgspec has standard library incompatibilities too
By the way, msgspec isn't fully compatible with json-here are the differences that I know about.
- Non-finite floats are encoded as
nullrather than the standard library'sNaNandInfinity:
>>> json.dumps(float("inf")) 'Infinity' >>> msgspec.json.encode(float("inf")) b'null'I'd call this an improvement, since
Infinityisn't valid JSON and other parsers will reject it. But it is a change, so if you rely on round-tripping those values, take note.
- msgspec also only coerces keys that are string-like or number-like, so booleans and
Noneare still rejected:
>>> json.dumps({None: "nothing"}) '{"null": "nothing"}' >>> msgspec.json.encode({None: "nothing"}) Traceback (most recent call last): ... TypeError: Only dicts with str-like or number-like keys are supportedSuch keys should be rarer than numbers in practice.
What might come next in django-msgspec
django-msgspec covers the same ground as django-orjson today, but msgspec is a broader library than orjson, so there's more potential for future development.
First, msgspec's typed container class, Struct, lets you decode and validate in a single pass:
>>> import msgspec
>>> class Sale(msgspec.Struct):
... product_id: int
... count: int
...
>>> msgspec.json.decode(b'{"product_id": 1, "count": 2}', type=Sale)
Sale(product_id=1, count=2)
>>> msgspec.json.decode(b'{"product_id": "one", "count": 2}', type=Sale)
Traceback (most recent call last):
...
msgspec.ValidationError: Expected `int`, got `str` - at `$.product_id`
This could be useful for combining with Django views, DRF parsers, or even forms.
Second, msgspec can serialize and deserialize other data types, so they might be worth integrating.
Happy to take suggestions on the design here, on the issue tracker.
Fin
Please try out django-msgspec today and let me know how it goes.
May all your messages be to specification,
-Adam
08 Aug 2026 2:24am GMT
07 Aug 2026
Django community aggregator: Community blog posts
Issue 349: Django 6.1 and a DSF Executive Director
News
Call for applicants for a Django Executive Director
The DSF is hiring its first Executive Director to run fundraising, operations, staff, and legal/compliance for the Foundation. The role is US-based (no visa sponsorship). Applications (resume, optional cover letter, and a vision statement) are due September 14, 2026.
Django 6.1 released
Django 6.1 introduces model field fetch modes, database-level delete options for ForeignKey.on_delete, and dictionary-based email settings. Django 6.0 is now out of mainstream support and receives security and data loss fixes only until April 2027, so plan your upgrade before then.
Thank you to release manager Jacob Walls and the entire Django team on another happy feature release.
Releases
Django security releases issued: 6.0.8 and 5.2.17
The releases address high-severity spatial lookup flaws that could write files or make network requests, plus denial-of-service risks in language and geometry handling and potential XSS from unsafe admin URLField values. Upgrade to Django 6.0.8 or 5.2.17 as soon as possible.
Python 3.15.0 candidate 1 is here!
Python 3.15.0 candidate 1 is available for testing, giving projects a release candidate against which to check compatibility and build wheels.
Python 3.14.7 and 3.13.15 are now available!
Python 3.14.7 and 3.13.15 are available as bug-fix releases, so update your installations.
Wagtail CMS News
An agent-heavy roadmap for 2026
A standards-based look at what agent readiness requires, without the usual hype.
Sponsored Link
Find and fix Python errors faster - for free - with Honeybadger
When something breaks in production, logs tell you something happened. Honeybadger tells you why.
Honeybadger filters out noise and transforms your Python logs into context-rich issues so you can stop guessing and ship the fix faster.
Django Fellow Reports
Django Fellow Report - Jacob
Highlights this week included clearing release blockers for 6.1 and making the parallel test runner more fault tolerant. That means lots of tickets triaged, reviewed, and authored!
Django Fellow Report - Natalia
This week I prioritized time-sensitive security work 🥷, including finalizing patches, preparing and validating backports, and sending pre-notifications ahead of the release. Alongside that, I focused on Tim's "Sprint quickstart" PR 🏃➡️ and attended meetings 🎧. Otherwise, I spent time preparing my DjangoCon US talk (it is coming together well, even if I am a bit wary of expectations around my htmx expertise 🎤).
Django Fellow Report - Sarah
Focus of the week was mostly around helping finalize the security release and reviewing the GSOC Selenium to Playwright migration (which is looking in good shape!).
Articles
Django Claude Skills
Mariatta built a set of Django house rules for Claude to follow in her own projects, covering formatting (black, isort, djlint, flake8), full test coverage as a merge requirement, has_perm() over group-membership checks for permissions, and a single Markdown email template that renders both text and HTML. She's explicit that these are personal conventions, not Django community consensus, and that a project's own style wins when contributing elsewhere. She published it as an Astro static site rather than plain Markdown, since she'd rather read the rules in a browser than as agent-only files.
What I love about Django
The best parts of Django are the ones you stop noticing - a tour of the abstractions that have given Buttondown the most leverage over the years.
Django Doesn't Have to Feel Old: Modernize Your Frontend with Vite
A walkthrough of wiring django-vite into a Django project: hot module replacement so CSS edits show up without a full page reload, explicit imports instead of global script-tag collisions, and a vite_asset template tag that resolves to the dev server or hashed production files depending on DEBUG.
Django 6 isn't a revolution. And that's exactly why I like it.
An experienced developer's take on years of building applications with Django and why the latest update continues a philosophy that many modern frameworks seem to have forgotten.
Python: how time-machine is O(1) where freezegun is O(n)
The comparison explains why time-machine avoids freezegun's O(n) slowdown when mocking dates and times, building on benchmarks that found it 100 to 200 times faster across two project sizes.
Core Dispatch #9
Python 3.15.0 release candidate 1 is out, followed by maintenance releases Python 3.14.7 and 3.13.15. The latest Core Dispatch also tracks two new PEPs entering the queue.
Devtools must be open source
Agents can now rebase your local tweaks against upstream automatically, so personalizing a tool costs a prompt instead of an ongoing maintenance burden. That's the case for why closed-source tools like Claude Code can't be personalized the way open alternatives like Codex or Pi can.
DjangoCon US
Save 10% on DjangoCon US 2026 registration
DjangoCon US 2026 runs August 24 to 28 in Chicago, now under three weeks away. Register through our link to take 10% off. Time is running out to get your ticket. Get yours before they are gone!
Events
Chicago Like a Local: Things to Do During DjangoCon US 2026 (Part 1)
Conference chair Keanya Phelps rounds up Italian beef at Mr. Beef and Al's, ramen and vinyl records at Wax in West Town, shuffleboard at Electric Shuffle, and rooftop lake views at Offshore on Navy Pier. She also flags the free Chicago House Music Festival in Millennium Park, August 27 to 30, which overlaps the last days of the conference.
Django Job Board
A few openings this week, including Django and Python Software Foundation roles.
Executive Director at Django Software Foundation 🆕
Security Developer at Python Software Foundation
Senior Full Stack Engineer at Hive Collective
Senior Backend Engineer at MyOme
Python + TypeScript Engineers at Fusionbox
Videos
PyCon US 2026
PyCon US 2026 videos are up.
Projects
wemake-services/django-modern-rest
Modern REST framework for Django with types and async support!
wsvincent/django-skills
Unofficial Django skills based on Django docs and community best-practices.
07 Aug 2026 3:00pm GMT
06 Aug 2026
Planet Python
Django Weblog: Call for applicants for a Django Executive Director
The Django Software Foundation is announcing a call for an Executive Director. The Executive Director is the operational leader of the Django Software Foundation, a paid position reporting to the Board of Directors, responsible for setting the Foundation's strategic direction and turning it into action, while managing day-to-day operations. They serve as the main connector between the Board, staff, community, and sponsors.
The Django Software Foundation (DSF) is a 501(c)(3) nonprofit that develops and maintains Django, a free and open-source web application framework. The Foundation exists to support the development of Django by sponsoring sprints, meetups, gatherings and community events; to promote the use of Django among the web development community; to protect the framework's intellectual property and long-term viability; and to advance the state of the art in web development.
This is a new role for the Foundation. Django itself has been around since 2005, but the DSF wasn't founded until 2008, and the person who takes on this role will play a key part in maturing the Foundation's internal structure, helping ensure the DSF can properly support and sustain this important ecosystem going forward. The position is initially for a period of one year, renewable subject to an annual performance evaluation. Depending on the candidate, the role may be part-time or full-time.
Beyond running the Foundation, the Executive Director is a representative of the DSF itself. They embody Django's welcoming culture and help the community sustain the framework's home. The Executive Director is often called upon to represent the Foundation publicly, including at Django conferences and events, and to grow awareness of the DSF as an organization, distinct from the framework it supports.
Responsibilities
Executive Director duties include (but are not limited to):
- Fundraising: leading sponsorship development, corporate and individual membership growth, and partner relationships, including support for the jump from our current 300K USD annual fundraising goal to 500K USD. At the current funding level (around 300K per year), a full-time Executive Director isn't yet sustainable. We'd like to fix that, and we want you to lead that change.
- Admin and operations management: day-to-day operations and administration of the DSF, financial reporting, grant management, and the general running of the organization. Over time, helping grow the DSF into a more mature organization by establishing the operational foundations that support the nonprofit's growth.
- Managing the DSF Assistant and Fellows: overseeing the DSF Assistant and the Django Fellows program, the paid maintainers funded by the DSF.
- Marketing and outreach: community outreach and communications, representing the DSF publicly (for example, conference representation), and growing awareness of the Foundation as distinct from the framework.
- Legal, trademark, and follow-ups: overseeing international trademark policy enforcement, creating, signing, and renewing contracts, handling legal correspondence, and the unglamorous administrative follow-through that keeps a 501(c)(3) compliant. First-hand legal knowledge isn't required here; you'll work with counsel.
- Working group check-ins: regular coordination with the DSF working groups, the volunteer committees handling events, AI, accessibility, fundraising, and more.
- Working with our Django events and conferences like our DjangoCons.
Requirements
An Executive Director is responsible for fundraising, operations, communications, and community coordination. This is a broad remit, and it isn't our expectation that you come into the job an expert in every part of it. We hope you'll have solid experience in a few of these areas, particularly the ones most central to the role (fundraising and partnership development, nonprofit operations, and stakeholder communication). A willingness to learn and a demonstrated history of doing so are more important than comprehensive knowledge.
The areas you can expect to work across include (and are not limited to):
- Fundraising, sponsorship, and partnership development
- Nonprofit operations, financial reporting, and grant management
- Contracts, trademark, and 501(c)(3) compliance (in coordination with counsel)
- Public representation, marketing, and communications
- Coordinating staff, volunteers, and working groups
- Technical knowledge is not required, but is a nice-to-have:
- Knowledge of, or familiarity with, the Django and Python community
- Familiarity with open source licenses and communities
And required professional skills such as:
- Conflict resolution
- Time management and prioritization expertise
- Ability to focus in short periods of time and do substantial context switches
- Self-awareness to recognize their own limits and reach out for help
- Relationship-building and coordination with the Board, staff, working groups, sponsors, and external parties
- Tenacity, patience, compassion and empathy are essential
Therefore, a Django Executive Director requires the skills and judgment of an experienced nonprofit leader who is comfortable with fundraising, operations, and coordination with community stakeholders. Open-source experience and familiarity with the Django or Python community in particular are a big plus.
Being part of the Django community isn't a prerequisite for this position. We'll consider applications from anyone with a proven history of nonprofit leadership or comparable experience in an open-source or mission-driven community, but this is a remote position based in the United States, and unfortunately we are not able to offer visa sponsorship for this role.
The DSF is an equal opportunity employer. We welcome applicants of every background and don't discriminate on the basis of race, color, religion, gender, gender identity or expression, sexual orientation, national origin, disability, age, or veteran status.
How to apply
If you're interested in applying for the position, please submit your application via hiring@djangoproject.com. Your application should include:
- A cover letter (optional)
- A resume or CV
- A brief vision statement (500 to 1000 words) addressing your understanding of the Foundation's current position, the key opportunities and challenges you see for the Foundation, and your approach to the role
References may be requested during the interview process.
The compensation for this role is a base salary of $90,000 to $120,000, plus a bonus of up to $60,000 tied to our progress toward the $500,000 fundraising goal, which we'll tier as we work toward it. Depending on the candidate, the DSF will consider a part-time position and adjust the salary accordingly.
Applicants will be evaluated based on the following criteria:
- Relevant nonprofit leadership and operational experience
- Track record in fundraising and partnership development
- Understanding of the position and of the DSF's current stage
- Clarity, formality, and precision of communications
- Familiarity with open source and/or the Django and Python community
- Strength of reference(s)
Applications will be open until midnight Central Time, September 14, 2026, with the expectation that the successful candidate will start around November 1, 2026 (to be confirmed).
Reference: Announcing the Search for a DSF Executive Director (Django Project blog, June 17, 2026).
06 Aug 2026 2:45pm GMT
Planet Twisted
Hynek Schlawack: Production-ready Python Docker Containers with uv
Starting with 0.3.0, Astral's uv brought many great features, including support for cross-platform lock files uv.lock. Together with subsequent fixes, it has become Python's finest workflow tool for my (non-scientific) use cases. Here's how I build production-ready containers, as fast as possible.
06 Aug 2026 12:00am GMT
Planet Python
Hynek Schlawack: Production-ready Python Docker Containers with uv
Starting with 0.3.0, Astral's uv brought many great features, including support for cross-platform lock files uv.lock. Together with subsequent fixes, it has become Python's finest workflow tool for my (non-scientific) use cases. Here's how I build production-ready containers, as fast as possible.
06 Aug 2026 12:00am GMT
03 Aug 2026
Django community aggregator: Community blog posts
Running Headscale on my own infra (and finally killing my WireGuard setup)
Hello everyone 👋
This one started in the dumbest possible way: I wanted to check on my 3D printer from outside my house.
I have a Bambu printer running in LAN-only mode, and I use OctoApp to control it from my phone. Works great at home. Useless the moment I leave. The usual answer is OctoEverywhere, which is a lovely project, but it means my printer traffic goes through someone else's servers, and I already run enough infrastructure that this felt silly.
So I went looking for a way to just… have my home network with me. And I ended up rebuilding my entire remote access setup in an afternoon.
What I had before
My old setup was, in hindsight, a bit of a Rube Goldberg machine:
- A Hetzner VPS running WireGuard
- A node at home connected to that WireGuard tunnel
- Nginx Proxy Manager on that node
- A Pi-hole for the WireGuard side, resolving things to
10.0.0.xaddresses - Another Pi-hole for my actual home network, resolving to
192.168.0.x
Two Pi-holes. Two address ranges. Two mental models of "where am I right now". It worked, but every time I added a service I had to think about which side of the tunnel it lived on.
What I actually wanted was much simpler: when I'm away, I want my phone to behave exactly like it's sitting on my home WiFi. Same IPs, same DNS, no difference at all.
That's a mesh VPN, and the nicest one is Tailscale.
I don't trust free
Tailscale is excellent. I want to say that first, because what follows is going to sound like criticism and it isn't. The client is great, the free tier is generous, and for most people it's the right answer.
But every time I look at a free tier this good, I catch myself doing the same mental arithmetic: someone is paying for this, and it isn't me. Tailscale is a venture-funded company. Free tiers built on top of venture funding have a well-documented lifecycle, and the last chapter is rarely "and it stayed free and generous forever". The limits shrink, or a device cap appears that you're already over, or the company gets acquired by someone with different ideas.
I'm not predicting that Tailscale does any of this. I have no reason to think they will. But I built my remote access on a WireGuard tunnel I control, and moving to something where a company I don't control holds the keys to my entire home network felt like a downgrade, even if the software is better.
I've also been burned recently enough that I'm not in a trusting mood. I wrote a whole angry post a few months ago about paying $100 a month for a service and getting less than 24 hours of notice before it changed underneath me. That was a paid tier. If that's what happens when I'm a customer, I'm not going to build my house on a free one.
So: I like the idea, I don't trust the arrangement. Which is exactly the situation self-hosting exists for.
Enter Headscale.
What Headscale actually is
Headscale is an open source implementation of the Tailscale control server. Your devices still run the official Tailscale client, they just point at your server instead of Tailscale's.
The important thing to understand is what the control server does and doesn't do. It handles key exchange, device registration, ACLs, and DNS settings. It does not sit in the middle of your traffic. Once two devices know about each other, they talk directly. So Headscale being a small container on a cheap VPS is completely fine.
I put it on the same Hetzner box that was already running WireGuard, because it already has a public IP and I was going to be deleting the WireGuard side anyway.
The Docker Compose setup
Headscale is CLI-first, which is fine, but I got tired of typing docker exec headscale headscale ... within about four minutes. So I also added Headplane, a web UI for it.
Directory structure first:
mkdir -p ~/headscale/config/{headscale,headplane}
cd ~/headscale
wget -O config/headscale/config.yaml https://raw.githubusercontent.com/juanfont/headscale/main/config-example.yaml
wget -O config/headplane/config.yaml https://raw.githubusercontent.com/tale/headplane/main/config.example.yaml
And the compose file:
services:
headplane:
image: ghcr.io/tale/headplane:latest
container_name: headplane
restart: unless-stopped
ports:
- "3003:3000"
volumes:
- ./config/headplane/config.yaml:/etc/headplane/config.yaml
- ./config/headplane/lib:/var/lib/headplane
# Shared path to the Headscale config. This has to match
# `headscale.config_path` in the Headplane config.
- ./config/headscale/config.yaml:/etc/headscale/config.yaml
- /var/run/docker.sock:/var/run/docker.sock:ro
headscale:
image: headscale/headscale:latest
container_name: headscale
restart: unless-stopped
command: serve
labels:
# Absolutely necessary for Headplane to find Headscale.
me.tale.headplane.target: headscale
ports:
- "8083:8080"
volumes:
# Same host path in both containers. This matters.
- ./config/headscale/config.yaml:/etc/headscale/config.yaml
- ./config/headscale/lib:/var/lib/headscale
Nginx handles TLS and public exposure, same as every other service on that box. If you're not putting a firewall in front of these, bind the ports to 127.0.0.1 instead ("127.0.0.1:8083:8080"), otherwise Docker happily opens them to the internet and walks around UFW while it's at it.
Note the me.tale.headplane.target label on the Headscale container. That's how Headplane finds it, and it's how Headplane restarts Headscale when you change DNS settings from the UI. Note also that both containers mount the Headscale config from the same host path. Headplane reads it directly, so if the paths drift you get a UI that shows you stale settings.
Headscale config
The parts of config/headscale/config.yaml that matter:
server_url: https://headscale.example.com
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
database:
type: sqlite3
sqlite:
path: /var/lib/headscale/db.sqlite
dns:
magic_dns: true
base_domain: ts.example.com
override_local_dns: true
nameservers:
global:
- 1.1.1.1
Leave nameservers.global pointing at a public resolver for now. We'll swap it for the Pi-hole later, once the Pi-hole has joined the network and has an address to point at.
base_domain has to be different from your server_url domain, otherwise Headscale refuses to start.
Headplane config
In config/headplane/config.yaml:
server:
host: "0.0.0.0"
port: 3000
base_url: "https://headplane.example.com"
cookie_secret: "<32 char random string>"
cookie_secure: true
data_path: "/var/lib/headplane"
headscale:
url: "http://headscale:8080"
config_path: "/etc/headscale/config.yaml"
api_key: ""
Generate the cookie secret with openssl rand -hex 32.
The url is the internal Docker address: container name, container port. Not whatever port you mapped on the host. The two containers talk over the compose network.
Then start Headscale on its own, create a user and an API key:
docker compose up -d headscale
docker exec headscale headscale users create myuser
docker exec headscale headscale apikeys create --expiration 90d
Paste that key into api_key and bring up Headplane. The key is only shown once, so if you lose it, just make another one. apikeys list only shows the prefix.
Nginx
Two vhosts, nothing exotic. But the Headscale one needs WebSocket passthrough, and this is the first place I lost time (more on that below):
location / {
proxy_pass http://127.0.0.1:8083;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_redirect off;
proxy_read_timeout 5m;
}
Headplane is a boring proxy block, no special headers needed. Certbot for certs, as usual.
Pi-hole as the tailnet DNS
This is my favourite part of the whole setup, and the reason I bothered.
Join your home Pi-hole to the network like any other device:
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --login-server https://headscale.example.com
tailscale ip -4
Take that 100.x.x.x address, put it in Headscale's dns.nameservers.global, and restart the container.
Now every device on the tailnet uses my home Pi-hole for all DNS. Which means:
sonarr.example.casaand every other internal domain resolves from anywhere, because Pi-hole's Local DNS Records answer for them- My phone gets Pi-hole ad blocking on cellular data, not just at home
Neither of those is new, I had both with the old WireGuard setup. The difference is that I now get them from one Pi-hole instead of two, and I didn't have to think about it. The Pi-hole joined the tailnet, I pointed Headscale at it, and every device I add from now on inherits both for free.
override_local_dns: true is what forces this. Without it, your phone keeps using whatever DNS the carrier hands it, the tunnel works fine, and your internal domains silently fail to resolve.
The subnet router
A mesh VPN only connects the devices that are on it. My printer can't run a Tailscale client. Neither can most of the stuff I care about.
The fix is a subnet router: one device on your LAN that advertises the whole network to everyone else. I used the Pi-hole box, since it was already joined:
sudo tailscale up --login-server https://headscale.example.com \
--advertise-routes=192.168.0.0/24
Two things have to happen after this or nothing works, and I hit both.
First, IP forwarding:
echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf
Second, and this is the one that got me: routes are double opt-in. The node advertises, and then the control server has to approve. Advertising alone does nothing.
docker exec headscale headscale nodes list-routes
docker exec headscale headscale nodes approve-routes --identifier 1 --routes 192.168.0.0/24
The moment I ran that second command, everything worked. Printer, internal domains, random machines on my LAN, all reachable from my phone on cellular.
Clients
You don't need a special app. The official Tailscale clients all support custom control servers.
On Android, install from Play Store or F-Droid, go to "Accounts", tap the kebab menu, and select "Use an alternate server". Enter your Headscale URL and log in.
On iOS it's slightly more buried: install from the App Store, tap the account icon, "Log in", then use the options menu to pick "Use custom coordination server".
macOS is the one with a real trap. Use the standalone build, not the Mac App Store one, because the App Store version is sandboxed and fights you on custom control servers. brew install tailscale works, or grab the standalone package from Tailscale's download page. Then:
sudo tailscale up --login-server https://headscale.example.com
tailscale set --accept-routes
That --accept-routes is easy to forget. Without it the client joins fine and then can't see anything on your LAN.
The gotchas that cost me the most time
Four things ate most of my afternoon. All of them had error messages that pointed somewhere other than the actual problem.
Headscale listening on 127.0.0.1 inside its own container
Headplane kept logging this:
Error while validating API key: [object Object]
I regenerated that API key three times. The key was fine. The real problem was one line above in the Headscale logs:
INF listening and serving HTTP on: 127.0.0.1:8080
127.0.0.1 inside a container means that container only. Not the compose network, not the other container sitting right next to it. Headplane literally could not open a connection, so it couldn't validate anything, and reported that as an auth failure.
Set listen_addr: 0.0.0.0:8080 in the Headscale config. Your host port mapping is a separate concern and keeps working the same.
Nginx eating the WebSocket upgrade
Clients wouldn't connect, and Headscale said:
WRN no upgrade header in TS2021 request. If headscale is behind a reverse proxy,
make sure it is configured to pass WebSockets through.
At least this error tells you exactly what's wrong. My nginx config was a copy-paste of the same reverse proxy block I use for every other service on that box, which has no Upgrade handling because nothing else needs it.
The commonly recommended fix uses a map block in the http context:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
The idea is that plain HTTP requests to the same vhost get Connection: close instead of a bogus Connection: upgrade. If you put that map somewhere nginx doesn't load it into the http context, you get unknown "connection_upgrade" variable and nothing starts.
I'll be honest: I got that error, commented the Connection line out entirely to keep moving, and it worked. It's on my list to go back and do properly, because "it worked when I removed the correctness" is not a state I enjoy leaving things in.
The routes CLI moved
Every guide and blog post out there tells you to run:
headscale routes list
headscale routes enable -r 1
On 0.29 that gives you unknown command "routes". It's now under nodes:
headscale nodes list-routes
headscale nodes approve-routes --identifier 1 --routes 192.168.0.0/24
Related: --user wants a numeric ID now, not a username. headscale users list gives you the number.
When in doubt, headscale --help is more current than anything you'll find in a search result. I lost a few minutes to blog posts written against older versions before I just asked the binary.
Pi-hole ignoring the tailnet
Tunnel up, subnet routes approved, ping 1.1.1.1 working, and google.com resolving to nothing.
Pi-hole's default interface listening behaviour doesn't answer queries arriving on the tailscale0 interface. Settings, then DNS, then set it to listen on all interfaces and permit all origins. Or scope it to the tailnet CIDR if you want to be tidier about it than I was.
The bash alias
Small thing, big quality of life improvement:
alias headscale='docker exec headscale headscale'
Now every command in every tutorial you find online works verbatim, without mentally prefixing the docker part every time. Just remember it's there if you ever install the real binary on the same box, or you'll spend an entertaining twenty minutes wondering why the local install ignores your config file.
What I still need to do
I'm not going to pretend this is finished.
The old WireGuard tunnel and its dedicated Pi-hole are still running. Headscale does everything they did, but I'm leaving them up until I've gone through the Nginx Proxy Manager config and confirmed nothing still points at a 10.0.0.x address. Deleting infrastructure at 2 AM after a successful afternoon is how you learn what depended on it.
Was it worth it?
Yes, and for reasons beyond the printer.
The setup collapsed two Pi-holes and two IP ranges into one mental model. My phone now behaves identically at home and on cellular. Every internal service I add from now on works remotely for free, without port forwarding or a new proxy host or any thought about which side of a tunnel it lives on.
And no ports are open to my LAN. The only publicly reachable thing is the Headscale control server, which hands out keys and doesn't carry traffic.
The total cost was one afternoon, most of which was spent on four error messages that pointed at the wrong thing. Hopefully this post saves you those.
If you want to check the printer thing specifically: Bambu printers in LAN-only mode expose MQTT on 8883 and FTPS on 990. There's no web UI, so don't waste time typing the IP into a browser like I did. Once you're on the tailnet, OctoApp just takes the same local IP and access code you use at home.
See you in the next one!
03 Aug 2026 5:00am GMT
23 Jun 2026
Planet Twisted
Glyph Lefkowitz: Adversarial Communication
As I have discussed in previous posts, "AIs" can make mistakes. In fact, they do make mistakes, and their mistake-making patterns are such that where and how they will make mistakes is both uncertain and constantly changing.
Thus, in any scenario where you want to attempt to make "productive" use of "AI", you must have a system in place for checking every result. Not checking some results; checking every result. If each result might have a consequence for you (and if it didn't have a consequence, why bother automating it?) and you cannot predict in advance which kinds of results will need verification, then verification is always required.
The verification often ends up being just as expensive as doing the work in the first place, which means that if you want your usage of "AI" to be personally profitable, you have to find someone else to externalize the cost of verification onto. This person becomes your adversary, and, if you are successful, your "AI's" victim.
The Ladder-Climber And Their Reverse-Centaur Rungs
One way that this constellation of facts can straightforwardly assemble themselves into a dystopian nightmare is the phenomenon, described by Cory Doctorow, of the reverse centaur. This is when your employer non-consensually turns you into the verification system. The "AI" does the fun part of initially performing the work, and then you do the boring part where you check if the robot is right and clean up its messes, even if everyone already knows that it would, in aggregate, be cheaper for you to do the work in the first place.
Reverse centaurs can be made from any automation, not only "AI" automation. I think that there is a reason that this term happens to have emerged in the "age of AI", though, and not with earlier automation technologies (even those which were considerably more viscerally horrific). That reason is: the wrongness of "AI" output is not merely a technical feature that must be compensated for, it is a generalized externality.
As I mentioned above, if you are responsible for the entirety of the work, both extruding the "AI" output and checking it, it's usually cheaper to have humans do the entirety of the work to begin with. When humans do the writing directly, we can check as we go, and thus verification doesn't need to be as comprehensive.
When "AI" coding advocates say "code review is the bottleneck", what they are observing is that the LLM is still rolling the dice for each PR, and a human is still necessary to verify that each of those rolls is a winner. But calling this process "code review" is a bit of a misnomer; it's not really "code review" in the traditional sense, it's human understanding.
Before the advent of "AI", the human understanding was implicit in the process of writing the code in the first place1, and the code review was a way of diffusing and extending that understanding. Now that the code can be authored with no initial understanding taking place, that cost has not gone away, it has moved.
Human understanding was always the bottleneck.
However, this is taking a collaborative view of a software project, where satisfying the needs and solving the problems of your customers are the goals. We can see that "AI" is a bad tool to satisfy those goals, because all it's doing is converting the first half of the work, that of understanding the code as you write it, to understanding the agent's output as you read it.
What if, instead, we were to take the view that every software company is a Hobbesian nightmare, red in tooth and claw? In this view, the only goal of a software project is for the individual developers to make their promo cycles and get their bonuses. Given that there is only a certain amount of money to go around, this is a zero-sum game where each programmer wants to look more productive than their colleagues.
Pretty much every organization finds it easy to reward "productivity" as expressed by lines of code emitted, but the benefits of doing thorough and thoughtful design, analysis, and code review very difficult to reward. In this world, an LLM is an invaluable tool for the sociopathic ladder-climber, particularly if your legacy organization is still structuring their workflows as if the person prompting the bot is "writing" the code, and then they get to foist off the act of "reviewing" the code onto someone else.
Here, the prompter effectively externalizes the cost of the LLM's failures but internalizes any benefits. The prompter will vibe-code a big feature, so large that the assigned reviewer can't possibly comprehend it all effectively. When this happens, the reviewer will, eventually, be pressured to approve it, even if they can try to spot a few problems along the way. The reviewer has their own work to get back to, after all, the obligation to review the prompter's (read: the bot's) code is a drain on their time that they are not going to get rewarded for.
If this feature is a big success, the prompter gets a promotion. If it causes a big issue, well, the reviewer must not have been careful enough.
This is why LLMs are "good for coding", and also why their biggest promoters keep having outages.
The Generative Gish Galloper
Coding is the biggest "success story" of this type of adversarial communication, but it is by far not the only instance of such a thing. LLMs create a new form of leverage that can turn Brandolini's law from a linear advantage into an exponential one. If you are engaged in a political debate where you want to overwhelm the other side in nonsense, an LLM can generate bullshit faster than it is physically possible for a human being to type, let alone respond thoughtfully. There is an asymmetry to the utility of this weapon as well: only one side of the political spectrum wants to flood the zone and destroy trust in institutions and the concept of truth. There's a good reason that the fascists love it.
Straightforward Spam and Fraud
This is kind of obvious, but LLMs can generate lightly-customized, plausible-looking text much more quickly than any human being. This facilitates their use in fraud, spam, and scams. In a spamming or fraudulent interaction, once again, the costs are externalized onto the victim: the recipient of a spam message has to do all the work of "checking" the LLM's output. Spammers already expect very low hit rates from boilerplate, and if the LLM can increase those percentages from 1% to 5% the technology will pay for itself; they don't need anything like reliable accuracy.
Customer "Support"
If you have any kind of commercial relationship with a company, I probably don't even need to mention this: customer "support" bots are a misery. Everybody knows it at this point. But customer support is usually conceptualized by businesses as an adversarial interaction, because it is a cost center. They maintain internal metrics on time-to-resolution and try to optimize them. Implicitly, this creates a dynamic where the goal of the customer service agent's job is not to solve your problem, but to emit noise that will cause you to think your problem is resolved, or to give up, as fast as possible. Unsurprisingly, LLMs can emit this noise faster than humans can, getting those customers off the phone. But those customers will remember those interactions, and the story outside the TTR metrics is horrible.
Similarly to the situation in software development, LLMs can look very good on paper for customer support, but mostly what they are doing is illuminating the problems with the industry's existing metrics, by turning "winning the metrics battle against the customer" into a more obvious and immediate defeat for the company's long term reputation.
"Education"
In 2026 it is sadly a fact of life that students cheat all the time using "AI", and that this cheating is very successful, in that the teachers find it very hard to detect.
LLMs are great for cheating on schoolwork because the student is externalizing the work of the checking onto the teachers, who are often starting at a disadvantage to begin with, at least in the US.
My view is that this is happening because of a divergence in the way that students vs. teachers (or, more accurately, "the broader educational system") view grading.
When a student is asked to write an essay, the teachers see the effort as both intrinsically worthwhile for the student, as well as useful as a pedagogical tool to evaluate and react to the student's progress. The student, by contrast, sees a stumbling block designed to knock them off the path to success and into a permanent underclass. It is no wonder that the student sees "AI" as useful to their own goals and has no compunction about deploying it.
There is a bitter irony that the ability to understand the inherent value of actually writing the essay on their own is the sort of thing that students can really only learn by writing a bunch of essays. There's no way that I can think of which makes the benefit legible as long as a shortcut is available.
The net effect here is a downward spiral, where the already-wobbling educational system is sustaining an attack that it doesn't have the resources to recover from. The individual students' attacks against their teachers and their schools' grading systems might appear to momentarily succeed, but they will win the battle and lose the war.
Spamming "For Good"?
Usually when we talk about someone unilaterally choosing to enter into an adversarial relationship, that's an "attack" and for good reasons we have a negative impression of the attacker. However, I would be remiss if I did not point out that there are some cases where the relationship was already adversarial; just because you're the attacker doesn't mean that you are evil.
For example we might imagine use-cases like automatically filing appeals for prior authorizations against health insurance. It's relatively well-known at this point that the main way for-profit insurers maintain their margins is by denying claims right up to the line of the policies themselves being fraud, so using a spamming tool to fight them might be entirely justifiable2 in that case.
Similarly, using an LLM could be justified in a fight against a company refusing to honor a warranty. One could imagine using an LLM to immediately generate replies and escalations.
However, even in imagined cases like these, the underlying problem is that the insurers and the vendors already have a tremendous amount of structural power, so it is more likely that they will have the advantage in deploying a communications weapon like an LLM, as well as enacting policies to simply ignore any LLM-based communication that you might submit. Worse, if these strategies were to become widespread, they might provide an excuse to reject any communications by feeding them into an unreliable "LLM detector" and issuing an automated "computer says no" even to hand-written correspondence.
It is also worth stressing that these cases are imagined, as compared to the very real coworker-abuse, spam, scam, fraud, and disinformation campaigns being waged in real life today.
Therefore, while legitimate uses might exist, it's hard to imagine that there's anywhere they would be genuinely valuable and sustainable. In the best case "AI" will provide a temporary advantage for underdogs that will provoke an arms race which the resource-advantaged adversaries will win in the long run, in the worst case the arms race itself will cement permanent structural change that will make things worse.
"Search" By Stealing
Most of the adversarial utility of "AI" is on the "write" side, since write-amplification is more obviously aggressive than reading. But the "read" side of LLMs - summarization and question-answering - can be a form of attack as well.
To begin with, the act of reading itself is currently enormously destructive, but that's arguably not a fundamental aspect of this technology. They could set reasonable rate-limits and respect things like robots.txt, as search engines have for decades now. They could also refrain from committing criminal levels of copyright infringement. But, today, using "AI" tools does suborn this sort of out-of-control crawling.
More insidiously, consider the scenario described in this YouTube video. The LTT Bros decided to try Linux again, and in the course of so doing, they had problems. When trying to solve these problems, they were faced with a choice: they could consult Reddit, or they could ask an LLM. Asking an LLM would "gaslight the heck out of" them, but they still found it preferable, because they would at least get an answer without getting yelled at.
Initially this sounds great. But it also means that you want to extract knowledge from a community, while mechanically eliding any values or norms that the community may want to impart as part of offering that knowledge. As someone who spent many years in a community tech support role, this is worrying. Many requests for support are people asking how to do things that will momentarily solve a superficial problem but create a long-term reliability problem or even an immediate security risk, that the question-asker doesn't want to hear about. Consider the question "I'm tired of entering my password so much, how do I make it so my laptop unlocks automatically". An obsequious chatbot will helpfully tell you how to do this without pushback.
But, this is also a sort of ethically murky area. The Linux community is somewhat famously, for many years now, a toxic cesspool of general hostility, misogyny, etc. It is certainly a good thing that people can get access to this knowledge without subjecting themselves to abuse. But it also means that the people with the power and the privilege to change the community for the better can just quietly withdraw, rather than fixing the problems. It also means that the positive elements of culture cannot be transmitted, and people will have no opportunity to learn about unknown unknowns.
In this case, the "adversarial" communication is with society. The thing that using an LLM for search lets you do is withdraw from society and avoid forming any personal connections. There are some personal connections which are painful and annoying, and so that can feel like a momentary balm. But the need to make connections in general is, like, the concept of society itself.
Who Am I Hurting?
LLMs are good at adversarial communication. They are so good at it, relative to their other benefits, that they will tend to make communications adversarial if you are not remaining vigilant about the possibility that it might do so. My request to you, dear reader, if you are going to use such tools, is to always ask yourself, "who might I be hurting, if I use an LLM for this?"
If you're using an "AI", who is its adversary? If you haven't given it one yet, who might the "AI" turn into an adversary? Who might you overwhelm with an asymmetric amount of output, or, if you're receiving information and not sending it, who are you taking that information from without consulting?
Figure out the answers to these questions and conduct yourself accordingly; the answer might be "yourself".
Acknowledgments
Thank you to my patrons who are supporting my writing on this blog. If you like what you've read here and you'd like to read more of it, or you'd like to support my various open-source endeavors, you can support my work as a sponsor!
-
One of the reasons that software developers tend to prefer greenfield development is that when you are given a blank page, you can project your own specific understanding onto it. You can structure the codebase in a way that works for your brain, down to the variable naming conventions and the module layouts. LLM-assisted development makes everything into instant brownfield work, which makes developers instantly miserable; even those who are excited about the technology will frequently complain about how it feels like their agency has been stolen and their joy in the work has been diminished. But I digress. ↩
-
Modulo the massive amount of other externalities involved in using LLMs, of course, but I don't have the time or energy to get into those here. ↩
23 Jun 2026 8:06pm GMT
09 Jun 2026
Planet Twisted
Hynek Schlawack: How to Ditch Codecov for Python Projects
Codecov's unreliability breaking CI on my open source projects has been a constant source of frustration for me for years. I have found a way to enforce coverage over a whole GitHub Actions build matrix that doesn't rely on third-party services.
09 Jun 2026 12:00am GMT

