Hypermode Agents are here. Natural language agent creation, 2,000+ integrations, full code export if needed.

Read more

JUNE 26 2025

From stateless to stateful: chat agents with Modus v0.18

Learn how to build intelligent chat applications that maintain persistent memory and leverage institutional knowledge using Modus v0.18 stateful agents.

Dan Starns
Dan Starns
Developer Experience Engineer, Hypermode

Building chat applications with AI has always felt like reinventing the wheel. Every project implements similar patterns: parsing message history, reconstructing context, managing conversation state. Each interaction requires rebuilding the entire conversation from scratch, leading to inefficient response times and fragile systems.

With Modus v0.18's introduction of stateful agents, we can finally move beyond these limitations. This isn't just about performance—it's about enabling chat experiences that feel truly intelligent, persistent, and production-ready.

Let's tell this story by building a weather chat assistant that evolves from a simple stateless function to a sophisticated agent with persistent memory, tool calling capabilities, and institutional knowledge storage that accumulates wisdom over time.

The stateless function problem

History Loss Problem

First, let's see how we traditionally built chat with stateless functions. Here's a simple weather function:

type WeatherResponse struct {
    Message string `json:"message"`
    History string `json:"history"`
}

func Chat(query string, chat_history string) (WeatherResponse, error) {
    // Parse the entire chat history every single time
    var messages []ChatMessage
    if chat_history != "" && chat_history != "null" {
        err := json.Unmarshal([]byte(chat_history), &messages)
        if err != nil {
            // If parsing fails, we lose everything
            messages = []ChatMessage{}
        }
    }

    // Add current message and rebuild context
    messages = append(messages, ChatMessage{
        Role: "user",
        Content: query,
    })

    // Process with AI (details omitted for brevity)
    response := processWithAI(query, messages)

    // Rebuild and return history
    messages = append(messages, ChatMessage{
        Role: "assistant",
        Content: response,
    })

    historyJSON, _ := json.Marshal(messages)
    return WeatherResponse{
        Message: response,
        History: string(historyJSON),
    }, nil
}

The conversation problems are immediately apparent:

User: "I'm Sarah, and I live in San Francisco."
Assistant: "Hello! How can I help you today?"

User: "What's the weather like?"
Assistant: "I'd be happy to help with weather! Which city?"

User: "My city, the one I just mentioned."
Assistant: "I don't see a city mentioned. Could you specify?"

Every call starts from scratch. The assistant can't remember Sarah's name or location because it has to rebuild context from potentially corrupted or missing history strings. Information gets lost, conversations feel robotic, and users get frustrated.

Building data infrastructure with stateless functions

Before we build our intelligent agent, let's establish the foundation: a simple stateless function that sensors around the world use to report weather data to our system.

Let's set up our connections in modus.json:

Modus is an open source, serverless framework for building intelligent agents and APIs in Go or AssemblyScript (a TypeScript-like language).

Learn more about Modus, and how to get started in the Modus documentation.

{
  "connections": {
    "dgraph": {
      "type": "dgraph",
      "connString": "dgraph://your-graph.hypermode.host:443?sslmode=verify-ca&bearertoken={{API_KEY}}"
    }
  },
  "models": {
    "text-generator": {
      "sourceModel": "meta-llama/Llama-3.2-3B-Instruct",
      "provider": "hugging-face",
      "connection": "hypermode"
    }
  }
}

Now let's create a simple stateless function that weather sensors can call to store data:

import (
    "fmt"
    "time"
    "github.com/hypermodeinc/modus/sdk/go/pkg/dgraph"
)

// Stateless function - no memory between calls, stores data in institutional knowledge
func ImportWeather(city string, temperature float64, conditions string, sensorId string) (*string, error) {
    // Store in Dgraph for institutional knowledge
    mutation := dgraph.NewMutation().WithSetJson(fmt.Sprintf(`{
        "dgraph.type": "WeatherReading",
        "city": "%s",
        "temperature": %f,
        "conditions": "%s",
        "timestamp": "%s",
        "sensor_id": "%s"
    }`,
        city,
        temperature,
        conditions,
        time.Now().Format(time.RFC3339),
        sensorId,
    ))

    err := dgraph.ExecuteMutations("dgraph", mutation)
    if err != nil {
        return nil, err
    }

    result := fmt.Sprintf("Weather reading stored for %s: %.1f°C, %s",
        city, temperature, conditions)
    return &result, nil
}

This function has no state - it processes each sensor reading independently and stores it in our institutional knowledge graph. Perfect for data ingestion where you just need fast, reliable processing.

Let's populate our knowledge graph with sensor data from our three locations:

# Import weather reading from San Francisco sensor
mutation {
  importWeather(
    city: "San Francisco"
    temperature: 18.0
    conditions: "sunny"
    sensorId: "SENSOR_SF_001"
  )
}

Response:

{
  "data": {
    "importWeather": "Weather reading stored for San Francisco: 18.0°C, sunny"
  }
}
# Import weather reading from New York sensor
mutation {
  importWeather(
    city: "New York"
    temperature: 8.0
    conditions: "snowy"
    sensorId: "SENSOR_NY_001"
  )
}

Response:

{
  "data": {
    "importWeather": "Weather reading stored for New York: 8.0°C, snowy"
  }
}
# Import weather reading from Tokyo sensor
mutation {
  importWeather(
    city: "Tokyo"
    temperature: 12.0
    conditions: "overcast"
    sensorId: "SENSOR_TK_001"
  )
}

Response:

{
  "data": {
    "importWeather": "Weather reading stored for Tokyo: 12.0°C, overcast"
  }
}

Great! Now we've got the weather data flowing into our institutional knowledge graph from sensors in San Francisco, New York, and Tokyo. This data is available to our intelligent agents.

Building your first stateful agent

Persistent Memory

Now let's build a stateful conversational agent that can leverage this sensor data. The key difference from our stateless function is that this agent maintains persistent state between calls.

import (
    "time"
    "encoding/json"
    "github.com/hypermodeinc/modus/sdk/go/pkg/agents"
)

type WeatherAgent struct {
    agents.AgentBase
    state WeatherAgentState  // Personal conversation state
}

type WeatherAgentState struct {
    ChatHistory  string `json:"chat_history"`   // Conversation memory
    UserLocation string `json:"user_location"`  // Personal preference
    UserName     string `json:"user_name"`      // Personal context
}

func (w *WeatherAgent) Name() string {
    return "WeatherAgent-v1"
}

Let's implement the core message handling:

func (w *WeatherAgent) OnReceiveMessage(
    msgName string,
    data *string,
) (*string, error) {
    switch msgName {
    case "chat":
        return w.handleChat(data)
    case "get_chat_history":
        return &w.state.ChatHistory, nil
    default:
        return nil, nil
    }
}

func (w *WeatherAgent) handleChat(data *string) (*string, error) {
    // Extract user info from message (like name and location)
    w.extractUserInfo(*data)

    // Generate intelligent response with full context
    response := w.generateResponse(*data)

    // Update chat history automatically
    w.updateChatHistory(*data, response)

    return &response, nil
}

The crucial difference from stateless functions is how we handle conversation history. Instead of parsing it from scratch every time, the agent maintains its own persistent state:

func (w *WeatherAgent) updateChatHistory(userMessage, agentResponse string) {
    // Parse existing history
    var messages []ChatMessage
    if w.state.ChatHistory != "" {
        json.Unmarshal([]byte(w.state.ChatHistory), &messages)
    }

    // Add new messages
    messages = append(messages, ChatMessage{
        Role:      "user",
        Content:   userMessage,
        Timestamp: time.Now().UTC().Format(time.RFC3339),
    })

    messages = append(messages, ChatMessage{
        Role:      "assistant",
        Content:   agentResponse,
        Timestamp: time.Now().UTC().Format(time.RFC3339),
    })

    // Keep only last 20 messages to prevent infinite growth
    if len(messages) > 20 {
        messages = messages[len(messages)-20:]
    }

    // Save back to state - automatically persisted by Modus
    historyJSON, _ := json.Marshal(messages)
    w.state.ChatHistory = string(historyJSON)
}

Now we need functions to interact with our agent:

func init() {
    agents.Register(&WeatherAgent{})
}

func CreateWeatherChat() (id *string, err error) {
    info, err := agents.Start("WeatherAgent-v1")
    if err != nil {
        return nil, err
    }
    return &info.Id, nil
}

func SendWeatherMessage(agentId string, message string) (*string, error) {
    return agents.SendMessage(agentId, "chat", agents.WithData(message))
}

func GetWeatherHistory(agentId string) (*string, error) {
    return agents.SendMessage(agentId, "get_chat_history")
}

Let's test our basic stateful agent:

# Create our conversational agent
mutation {
  createWeatherChat
}

Response:

{
  "data": {
    "createWeatherChat": "agent_weather_001"
  }
}

Now we've got both systems working together:

  • Stateless functions for fast sensor data ingestion
  • Stateful agent for persistent conversational memory

Adding intelligence with tools

Tools & API Integration

Now let's supercharge our agent with tools that can query our institutional knowledge graph. This creates a system where each interaction leverages the accumulated sensor data.

First, let's define the tools our agent can use:

import (
    "github.com/hypermodeinc/modus/sdk/go/pkg/agents"
    "github.com/hypermodeinc/modus/sdk/go/pkg/models/openai"
)

func weatherTools() []openai.Tool {
    return []openai.Tool{
        openai.NewToolForFunction(
            "get_weather_from_graph",
            "Get latest weather data from institutional knowledge graph",
        ).WithParameter("location", "string", "City name"),

        openai.NewToolForFunction(
            "analyze_weather_patterns",
            "Analyze historical weather patterns from institutional data",
        ).WithParameter("location", "string", "City name").
        WithParameter("timeframe", "string", "Analysis timeframe (week, month, year)"),

        openai.NewToolForFunction(
            "save_user_info",
            "Save user information",
        ).WithParameter("name", "string", "User's name").
        WithParameter("location", "string", "User's location"),
    }
}

Now implement the tool execution that connects to our knowledge graph:

import (
    "fmt"
    "encoding/json"
    "github.com/hypermodeinc/modus/sdk/go/pkg/dgraph"
    "github.com/hypermodeinc/modus/sdk/go/pkg/models/openai"
)

func executeToolCall(tool_call openai.ToolCall) (*string, error) {
    params := parseParameters(tool_call.Function.Arguments)

    switch tool_call.Function.Name {
    case "get_weather_from_graph":
        return getWeatherFromGraph(params["location"])
    case "analyze_weather_patterns":
        return analyzeWeatherPatterns(params["location"], params["timeframe"])
    case "save_user_info":
        return saveUserInfo(params["name"], params["location"])
    }
    return nil, fmt.Errorf("unknown tool: %s", tool_call.Function.Name)
}

// Get weather from institutional knowledge graph
func getWeatherFromGraph(location string) (*string, error) {
    query := dgraph.NewQuery(`
        query getLatestWeather($city: string) {
            weather(func: eq(city, $city)) @filter(type(WeatherReading)) {
                city
                temperature
                conditions
                humidity
                timestamp
                sensor_id
            }
        }
    `).WithVariable("$city", location)

    response, err := dgraph.ExecuteQuery("dgraph", query)
    if err != nil {
        return nil, err
    }

    var data struct {
        Weather []struct {
            City        string    `json:"city"`
            Temperature float64   `json:"temperature"`
            Conditions  string    `json:"conditions"`
            Timestamp   string    `json:"timestamp"`
            SensorID    string    `json:"sensor_id"`
        } `json:"weather"`
    }

    if err := json.Unmarshal([]byte(response.Json), &data); err != nil {
        return nil, err
    }

    if len(data.Weather) == 0 {
        result := fmt.Sprintf("No institutional weather data found for %s", location)
        return &result, nil
    }

    // Get most recent reading
    latest := data.Weather[0]
    result := fmt.Sprintf(`Latest institutional data for %s:
        Temperature: %.1f°C
        Conditions: %s
        Source: %s
        Recorded: %s`,
        latest.City,
        latest.Temperature,
        latest.Conditions,
        latest.SensorID,
        latest.Timestamp)

    return &result, nil
}

// Additional tools like analyzeWeatherPatterns follow similar patterns:
// 1. Query Dgraph with time filters (week/month/year)
// 2. Aggregate data (averages, counts, trends)
// 3. Return insights about weather patterns over time

Update our chat handler to use tools:

import (
    "github.com/hypermodeinc/modus/sdk/go/pkg/models/openai"
)

func (w *WeatherAgent) handleChat(data *string) (*string, error) {
    model, _ := models.GetModel[openai.ChatModel]("text-generator")

    llmResponse := llmWithTools(
        model,
        weatherTools(),
        systemPrompt(),
        *data,
        w.state.ChatHistory,
        openai.ResponseFormatText,
        executeToolCall,
        3, // loop limit
        10, // max history
    )

    w.state.ChatHistory = llmResponse.ChatHistory
    return &llmResponse.Response, nil
}

Now our agent becomes truly intelligent, leveraging both personal memory and institutional knowledge:

User: "Hi, I'm Sarah from San Francisco. What's the weather?"
Agent: [saves user info] [checks institutional data]
"Hi Sarah! According to our San Francisco sensor (SENSOR_SF_001), it's currently 18°C with sunny conditions. I've saved your info for next time!"

User: "What about this weekend?"
Agent: [analyzes SF patterns]
"Based on our institutional weather patterns for San Francisco,
    we typically see sunny conditions 73% of the time this season. Perfect weather for weekend plans!"

User: "How's the weather in Tokyo?"
Agent: [fetches Tokyo data from graph]
"In Tokyo, our sensor shows 12°C with overcast skies. Still keeping San Francisco as your default location, Sarah!"

The complete experience

Context is critical for building effective agents!

Checkout our blog as we share what we've learned about why context matters, and how it transforms AI agent capabilities from merely functional to truly exceptional.

Read more 👉 Why context is critical for building effective agents.

Here's what makes stateful agents powerful - they maintain context across sessions while building institutional knowledge:

Session 1:

Persistent Memory

User: "Hi, I'm Sarah from San Francisco"
Agent: [saves: name=Sarah, location=San Francisco]
"Nice to meet you Sarah! I've saved your location."

User: "What's the weather like?"
Agent: [calls: get_weather_from_graph(San Francisco)]
"According to our San Francisco sensor, it's currently 18°C with sunny skies!"

Reload:

Persistent Memory

[User closes browser, comes back later]

Session 2:

Persistent Memory showing continued conversation

User: "What's the forecast?"
Agent: [calls: get_user_info(), analyze_weather_patterns(San Francisco, week)]
"Hi again Sarah! Based on our San Francisco sensor data patterns over the past week..."

User: "How about New York?"
Agent: [calls: get_weather_from_graph(New York)]
"In New York our sensor shows 8°C with snow. Still keeping San Francisco as your home location."

The agent maintains perfect continuity across sessions, browser restarts, and even system deployments. Each conversation builds on the last, creating an experience that feels genuinely intelligent.

Understanding different types of state

Our complete system now demonstrates three different approaches to data and memory:

1. Stateless Functions (ImportWeather):

  • No memory between calls
  • Fast sensor data processing
  • Stores in institutional knowledge graph
  • Perfect for high-volume data ingestion

2. Stateful Agent (WeatherAgent):

  • Personal conversation memory
  • Remembers user preferences
  • Maintains chat context across sessions
  • Creates personalized experiences

3. Institutional Knowledge (Dgraph):

  • Shared organizational data
  • Historical weather patterns from all sensors
  • Cross-team accessible information
  • Builds organizational wisdom over time
User: "Show me weather patterns for San Francisco"
Agent: [queries institutional knowledge graph]
"Based on our institutional weather data:
- 847 readings from SENSOR_SF_001
- Average temperature: 18.2°C this month
- Most common condition: sunny (67% of time)
- Pattern analysis shows typical seasonal behavior"

The agent uses personal state for immediate context ("Sarah prefers San Francisco"), while leveraging institutional knowledge for deep insights ("This temperature is normal based on our sensor network data").

The power of hybrid architecture

What makes this architecture powerful is how different components work together:

  • Sensors in San Francisco, New York, and Tokyo continuously feed data through stateless functions
  • Institutional knowledge accumulates from all sensor readings across time
  • Personal agents maintain individual user context while accessing shared organizational data
  • Conversations become more intelligent as both personal memory and institutional knowledge grow

Conclusion

This transformation represents a fundamental shift in how we build conversational AI. Where we once had to manually reconstruct context from fragile history strings, we now have agents that automatically maintain rich, persistent state while building institutional knowledge that compounds over time.

What started as a brittle function that lost context between calls became an intelligent system that:

  • Remembers users across sessions and makes informed decisions about when to call tools
  • Maintains conversation continuity even through system restarts
  • Builds institutional knowledge through continuous sensor data collection
  • Leverages organizational memory to provide insights impossible with stateless functions
  • Combines personal context with historical patterns for superior intelligence

The weather chat assistant demonstrates how modern AI architectures can maintain both personal context (agent state) and organizational learning (knowledge graphs), creating experiences that feel genuinely intelligent while building wisdom that benefits all future interactions.

With Modus v0.18's stateful agents, we're not just building better chat applications—we're building the foundation for intelligent systems that learn, remember, and improve continuously.

To learn more building stateful agents with Modus, checkout our open source Modus recipes repository 👉 Hypermode chat.