DEV Community

Jasper
Jasper

Posted on

Agents as first-class users of a Django app

Most "AI features" in web apps are a chat box bolted onto the side: a separate service, a separate account, and a separate idea of what's allowed. I wanted the opposite for DataGroceries, a kanban board and a wiki that install into an existing Django project. An AI agent should be a user of the app, just like the people on the team. It works as the person who connected it, under the same rules, through the same code. It shows up in the same history, and nothing gets a side door.

Here is what that took, and what I'd do again.

One code path: a tool is a REST view

Every app has a REST API, and every MCP tool is one of those views, run in-process. A tool is a few lines of description around a view that already exists:

Tool(
    'comment_on_ticket',
    'Comment on a ticket',
    "Add a comment, or a reply to one. The ticket's watchers and anyone @-mentioned hear about it at once.",
    TicketCommentsView,
    'POST',
    {'key': KEY, 'body': {'type': 'string', 'description': 'Markdown. ...'}, 'parent': {...}},
)
Enter fullscreen mode Exit fullscreen mode

When Claude calls comment_on_ticket, the MCP endpoint builds a request for TicketCommentsView.post and runs it. Validation, permission checks, notifications, history: it's all the code a script calling the REST API gets. The pages go through the same operations too. So the page, the API and the agent can't drift apart. There is no "agent version" of commenting that forgets to notify the watchers, or skips a check the form makes.

It also keeps the tool count honest. The board has 12 tools and the wiki 13, because that is what the apps do. Deleting tickets isn't a tool because it isn't in the API, on purpose.

The MCP endpoint itself is small: stateless JSON-RPC over plain HTTP, one endpoint for every installed app, each app adding its tools. No SDK, no long-lived connections.

The agent is a person, not a service account

There are no agent accounts and no shared API keys. An agent connects as someone: Ann connects Claude, and Claude can do what Ann may do, no more.

  • Who may use an app is a Django permission ("Can use the board"), granted to groups in the admin. The page and the tool check the same permission on every request. If Ann loses access, her agent loses it in the same moment.
  • Tokens have scopes per app, so a token for the board doesn't open the wiki.
  • Every change says how it was made. The history records "via Claude" (or the name of the token), so a ticket's activity feed shows Ann's change, via Claude. People can see what their colleagues' agents did, and the agent's changes notify the same people a person's would.

That last one matters more than I expected. Once an agent's changes look like a colleague's, with a name, a reason and a trail, people stop treating it as a black box and start treating it as a teammate whose work they can review.

Letting Claude in with your project's own login

This was the hard part. The goal was that someone pastes the endpoint's address into Claude (claude.ai, Claude Desktop, Claude Code) or VS Code, signs in with the login they already have, and is done. No second account, no API key copied around.

How it works:

  • Core is only a resource server. It checks credentials and issues nothing. A request without them gets a 401 that points at the protected resource metadata (RFC 9728), which names the authorization server.
  • An optional package, django-dg-connect, is that server, on django-oauth-toolkit. It uses PKCE only, accepts Client ID Metadata Documents from an allowlist of known clients instead of open dynamic registration, rotates refresh tokens and stores only hashes. The sign-in page it sends people to is your project's login page, so they sign in the way they always do.
  • Or none of ours at all. A project that already has API tokens, JWT or its own OAuth server lists its DRF authentication classes in one setting, and names its authorization server in another. The MCP endpoint then sends Claude there. Your people never get a token from us.

What it looks like in practice

DataGroceries is built on a DataGroceries board, with Claude connected. Claude reads the ticket, writes the code, comments when it's done, and moves it along. These are things I actually said to it over the last two days. They were in Dutch, so they're translated, but not polished:

Picking up work

  • "Can you go through the tickets? There must be something we can pick up."
  • "This one's done. Let's pick up the next ticket. I love finishing tickets…"
  • "Do G-6 now, then? And is every other ticket up to date?"

Making tickets

  • "Done. Can you also make a ticket for a little logo?"
  • "Agreed. Put it in the icebox."
  • "Yes, write the ticket and start implementing. I don't think anyone has downloaded it yet, so it doesn't need to be backwards compatible."
  • "Can you keep track on our board of the tasks and subtasks, and how far along we are?"

Writing things down

  • "Can you put our considerations and conclusions in G-15, and move it to the icebox? We'll do G-17 first."
  • "Build 1 and 2. But it can all go in G-47; no new ticket needed. Just update 47."
  • "Can't the text just go in the ticket?" That's where the draft of this post lives.
  • The link to a Reddit post I'd just made, and nothing else. Claude put it on the launch ticket.

Moving and relating

  • "I don't think the website matters much anymore. Do you? If you agree, we put it in Won't do."
  • "G-7: nice name! G-26: yes, let's look into that. G-14: let's build it so both can work." One message, three tickets.
  • "Yes, better." That was my answer when Claude suggested the CMS no longer belonged under the launch ticket. It removed the relation.

Asking

  • "What's the difference between G-2 and G-10?"

Most of these are short, because the context is already in the ticket. That's the point: the agent reads the ticket, so the conversation can be about the decision. And every change it made is on the board as mine, via Claude.

The board started as Eddy Board, the tracker Eddy Grid built for its own team and used with Claude the same way. That's where most of these ideas were tested first.

The rule underneath: a good default, and room to integrate fully

All of this follows one rule that I only put into words this week. DataGroceries may ship a default that's as good as we can make it. But a project must always be able to take that part over entirely, so the apps become part of the project rather than sit beside it: one account, one sign-in, one language.

Sign-in is one example. Notifications are another: by default they're email, written from templates you can override, in each person's own language, with one thread per ticket. A project that wants Slack, Teams, a digest or nothing at all names a class of its own. That class gets the whole event: what happened, who did it, and the ticket itself. Who may use an app is a Django permission by default, and a class of yours if you see your people differently. If a project ever has to keep two accounts, or ends up with one page in another language than the rest, the default has failed.

Try it

  • The demo: demo.datagroceries.com. You get an account with one click and can connect Claude in a minute. It resets every night.
  • The code: github.com/datagroceries/django-dg. It's MIT licensed, for Django 5.2 and 6.x.
  • Install: pip install django-dg-board django-dg-connect, add the apps and one URL include, and manage.py check tells you what's still missing.

If you've built something similar, or tried to and hit a wall, I'd like to hear how you approached it.

Top comments (0)