An Agentic Helpdesk

USE CASEAI
12 min read

Here at Dolthub Inc, we've been writing about AI since March. It feels like we have at least one blog a week on the field, how it is affecting our industry and our jobs, and how Dolt fits into the picture. However, one of our first posts on the subject was about Dolt agentic workflows. In that post, Tim described how agentic workflows benefit from the ability to branch and merge data, just like code. But talking about the potential benefits of Dolt agentic workflows is one thing, actually building an app that uses them is another. So, today, I'm excited to share a sample app that demonstrate agentic workflows built on top of an open source helpdesk system, backed by Dolt.

The Fork

The sample app is a fork of the Django Helpdesk project. Its goal is to allow agents to work on tickets in their own branches. When they complete their work, a Pull Request would be created. This would then be reviewed by an administrator and merged once the work is verified. The fork is available here.

Core Changes

Agentic Users

The first major change was to add an is_agent field to the UserSettings model. This allows us to differentiate between agents and regular users.

class Migration(migrations.Migration):

    dependencies = [
        ('helpdesk', '0040_alter_kbitem_remove_order_check'),
    ]

    operations = [
        migrations.AddField(
            model_name='usersettings',
            name='is_agent',
            field=models.BooleanField(
                default=False,
                help_text='Designates whether this user is an agent who can handle tickets. Agents have special permissions and responsibilities in the helpdesk system.',
                verbose_name='Is Agent?'
            ),
        ),
    ]

Agentic Branches and Sessions

One challenge with state and branches is any state stored in the database, is stored on a branch. When you change the branch you are working on, the state changes as well. Concretely, we need to be able to be able to have sessions that contain information about the agent's work, and the branch they are on. Two possible solutions are to explicitly change the branch used to store session data, or to store the session data in the signed cookie passed in with each request. While the first solution is possible in Django, it is easier to implement the second solution.

SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"

Adding this line to the settings.py file of the Django Helpdesk project configures Django to use signed cookies for session storage. A few other session settings are also modified to ensure we always use session cookies.

    SESSION_COOKIE_HTTPONLY = False
    SESSION_SAVE_EVERY_REQUEST = True

Now that we have a way to store data in the session, we can store the agent's branch as well as their intent in the session.

class AgentBranchNameMiddleware(MiddlewareMixin):
    """
    Middleware to create a branch name for agent users when they start a new session.
    
    For users where is_agent=True, when they start a new session, this middleware
    creates a variable named "branch_name" and sets it to be <username>-<random_string_of_8_characters>
    and stores it in the session.
    
    This middleware is optimized for session-based authentication for both web and API requests.
    """

    def process_request(self, request):
        """
        Check if the user is an agent and create a branch name if it's a new session.
        """
        # Skip if user is not authenticated
        if not hasattr(request, 'user') or not request.user.is_authenticated:
            return None

        # Skip if user is not an agent
        if not self._is_agent(request.user):
            return None
        
        # Ensure session exists and is properly initialized
        if not hasattr(request, 'session'):
            return None
        
        # For session-based authentication, check if we need to initialize the session
        # Note: signed_cookies sessions don't have session_key, so we skip the cycle_key call
        if not request.session.session_key and not self._is_signed_cookie_session():
            # Force session creation (only for database-backed sessions)
            request.session.cycle_key()
            
        # Check if branch_name is already set in session
        if 'branch_name' not in request.session:
            # Generate branch name: <username>-<random_string_of_8_characters>
            random_string = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
            branch_name = f"{request.user.username}-{random_string}"
            
            # Store in session
            request.session['branch_name'] = branch_name
            
            # Force session save to ensure persistence
            request.session.modified = True
            
            # Create Dolt branch
            self._create_dolt_branch(branch_name)

            # Log the branch name creation
            logger.info(
                f"Created branch name '{branch_name}' for agent user '{request.user.username}'",
                extra={
                    'method': request.method,
                    'path': request.path,
                    'user': f"{request.user.username} (ID: {request.user.id})",
                    'client_ip': _get_client_ip(request),
                    'headers': {},
                    'user_id': request.user.id,
                    'username': request.user.username,
                    'branch_name': branch_name,
                    'session_key': self._get_session_identifier(request),
                    'is_api_request': self._is_api_request(request),
                }
            )

        if 'branch_name' in request.session:
            branch_name = request.session['branch_name']
            self._checkout_dolt_branch(branch_name)

        return None

Our AgentBranchNameMiddleware checks if the user is an agent and creates a branch name for them if it doesn't already exist in the session. The branch name is generated as <username>-<random_string_of_8_characters>, and it is stored in the session and created in Dolt.

Then, regardless of whether the branch was created in this request or not, we check out the branch. Because Django creates a new database connection for each request, all future database queries run as a part of this request will be run on the agent's branch.

The final piece we store in the session is the agent's intent. This is a string that describes what the agent is working on, and it is used to create the commit message when the agent completes their work, and in the creation of the Pull Request. We provide a new API endpoint that the agent can use to set their intent.

@api_view(['POST'])
@permission_classes([IsAuthenticated])
def set_agent_intent(request):
    """
    API endpoint to set the intent session variable for agent users.
    
    Accepts:
    - intent: string (max 512 characters)
    
    Only available for agent users.
    """
    # Check if user is an agent
    try:
        is_agent = request.user.usersettings_helpdesk.is_agent
    except AttributeError:
        is_agent = False
    
    if not is_agent:
        return Response({
            'error': 'Not an agent user',
            'message': 'This endpoint is only available for agent users.'
        }, status=403)
    
    # Check if session is available
    if not hasattr(request, 'session'):
        return Response({
            'error': 'Session not available',
            'message': 'Session is required to set intent.'
        }, status=400)
    
    # Get intent from request data
    intent = request.data.get('intent')
    
    if intent is None:
        return Response({
            'error': 'Intent required',
            'message': 'The intent field is required.'
        }, status=400)
    
    # Validate intent is a string
    if not isinstance(intent, str):
        return Response({
            'error': 'Invalid intent type',
            'message': 'Intent must be a string.'
        }, status=400)
    
    # Validate intent length (max 512 characters)
    if len(intent) > 512:
        return Response({
            'error': 'Intent too long',
            'message': 'Intent must be 512 characters or less.'
        }, status=400)
    
    # Set intent in session
    request.session['intent'] = intent
    request.session.modified = True
    
    return Response({
        'success': True,
        'message': 'Intent set successfully',
        'intent': intent,
        'user_id': request.user.id,
        'username': request.user.username,
        'branch_name': request.session.get('branch_name'),
        'session_key': _get_session_identifier(request)
    })

Pull Requests

When an agent completes their work they can create a Pull Request. Taylor has written about how to create Pull Requests in about custom pull request workflows in Dolt here.

We created a page to view open pull requests, and a pull request details page (which I won't cover the specifics of but their source is part of the codebase) But in our case here, it starts with a new Django model:

class Migration(migrations.Migration):
    dependencies = [
        ("helpdesk", "0041_add_is_agent_field")
    ]

    operations = [
        migrations.CreateModel(
            name="PullRequests",
            fields=[
                (
                    "branch",
                    models.CharField(
                        verbose_name="PR Branch",
                        primary_key=True,
                        help_text="Agent created branch which needs to be merged, or discarded.",
                        max_length=64,
                    ),
                ),
                (
                    "intent",
                    models.CharField(
                        verbose_name="PR Intent",
                        max_length=512,
                        null=False,
                        blank=False,
                        help_text="Description of what the agent was attempting to do in this PR."
                    )
                ),
                (
                    "creation_date",
                    models.DateTimeField(
                        verbose_name="PR Creation date",
                        blank=False,
                        null=False,
                        help_text="Date this pr was created."
                    ),
                ),
                (
                    "resolution_date",
                    models.DateTimeField(
                        verbose_name="PR Resolution Date",
                        blank=True,
                        null=True,
                        help_text="Date this pr was merged or discarded."
                    ),
                ),
                (
                    "status",
                    models.IntegerField(
                        verbose_name="PR Status",
                        help_text="1. Open, 2. Merged, 3. Discarded",
                        blank=1,
                        default=1,
                        choices=[
                            (1, "1. Open"),
                            (2, "2. Merged"),
                            (3, "3. Discarded"),
                        ]
                    ),
                )
            ]
        )
    ]

This model stores the branch name, intent, creation date, resolution date, and status of the Pull Request.

Finishing a Session

When an agent is done with their work, the stored procedure FinishSession is called. This is called in response to the agent calling the api endpoint api/finish-agent-session/ or the session timing out. If a session times out before the agent is able to finish their work, it is helpful to be able to see what they were doing for debugging purposes. This also allows us to give the ability for agents to resume sessions in the future.

class Migration(migrations.Migration):
    dependencies = [
        ("helpdesk", "0042_pull_requests")
    ]

    operations = [
        migrations.RunSQL(
            sql='''
CREATE PROCEDURE FinishSession(branch_name VARCHAR(64), intent VARCHAR(512))
BEGIN
  CALL dolt_checkout(branch_name);
  SET @changes = (SELECT count(*) FROM dolt_status);
  SET @create_pr = 0;

  IF @changes > 0 THEN
    CALL dolt_commit('-Am', intent);
    SET @create_pr = 1;
  ELSE
    SET @branch_hash = (SELECT hash FROM dolt_branches WHERE name = branch_name);
    SET @branch_head_in_main = (SELECT count(*) FROM dolt_log('main') WHERE commit_hash = @branch_hash);

    IF @branch_head_in_main = 1 THEN
      CALL dolt_checkout('main');
      CALL dolt_branch('-d', branch_name);
    ELSE
      SET @create_pr = 1;
    END IF;
  END IF;
      
  IF @create_pr = 1 THEN
      CALL dolt_checkout('main');
      INSERT INTO helpdesk_pullrequests (branch, intent, creation_date, status) VALUES (branch_name, intent, now(), 1);
  END IF;
END
'''
        ), migrations.RunSQL(sql='''CALL dolt_commit('-Am', 'created FinishSession stored proc');'''),
    ]

This stored procedure checks for uncommitted changes in the agent's branch. If there are, it commits the changes with the intent as the commit message. If there are no uncommitted changes, it checks if all the commits in the agent's branch have already been merged. If they have, it deletes the agent's branch. If there are commits that have not been merged, it creates a Pull Request with the intent as the description.

A note on Django Migrations with Dolt

When Django migrations are run, they are run against the working set of the default branch, or the branch you configured in your settings.py file. If you were to try to create a new branch at this point, none of your schema changes would be on your new branch since the changes were never committed. It would be ideal to include a migration at the end of each of your migrations files with the line:

migrations.RunSQL(sql='''CALL dolt_commit('-Am', 'A description of the migrations');'''),

However, having this line at the end of your last migration file would be enough to ensure that the schema changes are committed to the branch you are working on, and that branches made off of this branch will have the schema changes as well.

A final migration

Finally we add a migration with a stored procedure for merging a pull request:

class Migration(migrations.Migration):
    dependencies = [
        ("helpdesk", "0043_finish_session_stored_proc")
    ]

    operations = [
        migrations.RunSQL(
            sql='''
                CREATE PROCEDURE MergePR(branch_name VARCHAR(64))
                BEGIN
                    CALL dolt_checkout('main');
                    SET @changes = (SELECT count(*) FROM dolt_status);

                    IF @changes > 0 THEN
                        CALL dolt_commit('-Am', 'pre-merge commit');
                    END IF;

                    CALL dolt_merge(branch_name);
                    UPDATE helpdesk_pullrequests SET status = 2, resolution_date = now() WHERE branch = branch_name;
                    CALL dolt_commit('-Am', 'post-merge commit');
                END
                '''
        ), migrations.RunSQL(sql='''CALL dolt_commit('-Am', 'created MergePR stored proc');'''),
    ]

This stored procedure checks out the main branch, commits any changes, merges the agent's branch into the main branch, updates the Pull Request status to "merged", and commits the merge.

Integrating with Claude Desktop

Claude Desktop interacts with the helpdesk system through the API, but it does so indirectly. It needs to be told about the capabilities of the API and how to use it. This is done by creating an MCP Server. The MCP Server is a Node.js application that provides an interface for Claude Desktop to interact with the helpdesk system and it's code is available here. To get it running, you need to add this configuration to your claude_desktop_config.json

{
  "mcpServers": {
    "doltdesk": {
      "command": "node",
      "args": ["<PATH_TO_DJANGO_HELPDESK_ROOT>/mcp-server/dist/index.js", "--log-file","<PATH_TO_LOG_FILE>"],
      "env": {
        "HELPDESK_BASE_URL": "http://127.0.0.1:8080"
      }
    }
  }
}

This tells Claude Desktop to run the MCP Server with the specified command and arguments, and to use the specified environment variable for the base URL of the helpdesk system. Now that we have this configured we need to make sure we are running Dolt, and that our Django Helpdesk is running on port 8080.

A Conversation with Claude

Once the MCP Server is running, you can start a conversation with Claude Desktop. Here is an example conversation:

Prompt:

Using the username: agent1 with the password: pass123WORD, authenticate into the doltdesk mcp server and then set the intent to "Answer user questions", and then find all tickets in the "User Question" queue and generate a followup with a made up answer to the user question. Once you have answered all questions call finish_agent_session.

Claude Desktop Response:

I'll help you authenticate into the doltdesk system and handle the tickets as requested. Let me start by authenticating with the provided credentials.Great! I'm already authenticated as agent1. Now let me set the intent as requested:Now let me get all the available queues to find the "User Question" queue:It seems there are no queues returned. Let me try listing all tickets first to see what's available:I can see the tickets but the queue information shows as "Unknown". Let me examine a few specific tickets that look like user questions to understand their content better:I can see that queue ID 5 appears to be the "User Question" queue based on the content of these tickets. Let me get a few more tickets to confirm and see which ones still need responses:Now let me list tickets specifically from queue 5 to get all the user questions:The queue filter doesn't seem to be working properly as it's still showing "Unknown" for the queue field. Let me check a few more ticket details to identify which ones are user questions and need follow-ups. Based on what I've seen, tickets 21, 23, 24, and 25 appear to be user questions from queue 5. Let me check a few more tickets to see if there are others:Let me check tickets in the 30s, 40s, and 50s to find more user questions:Let me check tickets in the 70s, 80s, and 90s for user questions:Perfect! I found several user questions from queue 5. Let me check the remaining few tickets to make sure I get them all:Now let me check a few more to see if there are any other user questions I missed:Based on my analysis, I found the following user questions from queue 5 that need follow-up responses:

1. Ticket #21: Detailed Drill Comparison and Recommendation (already has responses)
2. Ticket #23: Complete Apartment Tool Guide (already has responses)  
3. Ticket #24: Complete Workshop Safety Priority Guide (already has responses)
4. Ticket #25: Tool storage solutions for garage (needs follow-up)
5. Ticket #75: Tool calibration and accuracy maintenance (needs follow-up)
6. Ticket #85: Metalworking vs woodworking tool differences (needs follow-up)
7. Ticket #95: Ergonomic tool recommendations for arthritis (needs follow-up)
8. Ticket #100: Tool safety training resources (needs follow-up)

Let me provide follow-ups for the tickets that need responses:Perfect! I have successfully provided follow-up responses to all the user questions in queue 5 that needed answers. Here's a summary of what I accomplished:

**Tickets I Added Follow-ups To:**
1. **Ticket #25**: Tool storage solutions for garage - Provided comprehensive storage strategy
2. **Ticket #75**: Tool calibration and accuracy maintenance - Complete calibration guide  
3. **Ticket #85**: Metalworking vs woodworking tool differences - Transition guide with tool recommendations
4. **Ticket #95**: Ergonomic tool recommendations for arthritis - Arthritis-friendly tool suggestions
5. **Ticket #100**: Tool safety training resources - Community workshop safety training guide

**Tickets Already Answered:**
- Tickets #21, #23, and #24 already had comprehensive follow-up responses

Now I'll finish the agent session as requested:Task completed successfully! I have:

1. ✅ Authenticated as agent1 with the provided credentials
2. ✅ Set the intent to "Answer user questions" 
3. ✅ Found all tickets in the "User Question" queue (queue ID 5)
4. ✅ Generated comprehensive follow-up responses for 5 user questions that needed answers:
   - Tool storage solutions
   - Tool calibration guidance  
   - Metalworking vs woodworking differences
   - Ergonomic tool recommendations for arthritis
   - Safety training resources for community workshops
5. ✅ Finished the agent session with proper cleanup

All user questions in the queue now have helpful, detailed responses addressing their specific needs and concerns.

What did it do?

Well now that is a good question, and it's precisely why we are running our agent against a branch, and created a pull request. Let's check the UI to see:

Pull Request

Well that's good, we can see that the agent created a pull request with the intent "Answer user questions".

Pull Request Details

Now we can see the details of the pull requests. Rows that were added, rows that were modified, and rows that were deleted. Did the agent do what we wanted? Are we happy with the quality of the agent's work? If so, we can merge the pull request, or if not we can discard it, and figure out what went wrong. Maybe we need to change our prompt. Regardless as to the outcome of the agent's work, we can see what they did, and we can make a decision about whether to merge it or not.

Conclusion

AI agents are going to be a big part of our future. The ability to isolate the changes of AI agents is going to be a huge benefit to your workflows. In the future, it may be that AI agents are reviewing the work of other AI agents, but regardless, the ability to see what an agent did is going to be critical to successful agentic workflows, and it is why we believe that Dolt is the best database for agentic workflows. Do you have thoughts on the matter, or want to talk to us about using Dolt in your own agentic workflows? Come chat with us on Discord.

SHARE

JOIN THE DATA EVOLUTION

Get started with Dolt

Or join our mailing list to get product updates.