Module 4: Tools: The Agent That Acts on Real Systems

4. Connecting real systems: Gmail, Sheets, a database, and HTTP

Description

By the end of this lesson you'll be able to connect Gmail, Google Sheets, a Postgres database, and — when none of those cover the system you need — any HTTP API directly to the agent as real tools, so the agent acts on data that genuinely exists in your operation, not on test data made up for an exercise.

This matters because that's where an agent's real value lies for a support, sales, or operations team: not in answering generic questions well, but in being able to touch the systems where the data lives — the order in the production database, the email that lands in the team's correct inbox, the row in the spreadsheet someone reviews on Monday. An agent that only knows how to talk about those systems, without being able to query them or act on them, is a demo. An agent connected to the real systems is a working tool.

Connection to the module: in the previous lesson you connected native n8n tools — actions that search, create, and send information within the flow itself. Today you take the leap to real systems, with effects someone on your team sees on the other end: an email that genuinely arrives, a row that genuinely shows up on a shared spreadsheet, a query that genuinely reads your production database. You're going to lean on something left pending when Module 3 closed: there you built an agent with persistent memory that remembered what had been said, but had no way of knowing whether that data was still true. Today you close that gap — the agent stops trusting what it remembers and learns to ask the real system. The next lesson takes exactly the tools you connect today and puts explicit limits on them: which field you never trust to the model's decision, which action requires human confirmation. Today is the connection; the contract comes next.

From the practice room to the office's real keys

Think about a new customer-support employee's first week. During training, they practice with a fake system: mock orders, a sample spreadsheet, a test inbox nobody on the other end is going to read. It's useful for them to learn the procedure with no risk of breaking anything real. But there comes a moment when someone hands them the real keys: access to the production order system, the email account real notices to the team go out from, write permission on the spreadsheet HR reviews every week. From then on, every action they take has a consequence outside the training room.

The agent you built through the previous lesson is still in the practice room. Today you hand it the real keys. And in n8n, the difference between a practice tool and a real tool is almost never a special node type — it's which credentials you connect and which connector on the agent you use.

How any node becomes an agent's tool

Most of n8n's application nodes — Gmail, Google Sheets, Postgres, and dozens more — can be connected directly to the AI Agent node's ai_tool port, exactly like you connected the HTTP Request in lesson 5 of Module 1 or the Code Tool in Module 3's mini-project. You don't need a special "AI" node: the same Gmail node that would send an email in a traditional flow can receive instructions from the model when you connect it as a tool.

What changes when you connect a node this way is that its fields stop being limited to a fixed value or an expression over flow data — they can use the $fromAI() function, which asks the model to decide that value at the moment it calls the tool:

$fromAI(key, description, type, defaultValue)
ParameterRequiredWhat it does
keyyesThe value's identifier (letters, numbers, hyphens, and underscores) — the name the model uses to reference this piece of data.
descriptionnoThe text hint that tells the model what to put there. The more specific, the less the model has to guess.
typenostring, number, boolean, or json. Defaults to string.
defaultValuenoWhat to use if the model can't determine a value.

$fromAI() only works inside a node connected to the agent's tools port — in any other node in the flow it's an error. And, just as important as knowing it exists, is knowing that you don't have to use it on every field of a tool: you're going to see in this lesson's example that some fields are better left fixed, with an expression that reads real conversation data, precisely to keep them from being at the mercy of whatever the model interprets from the customer's message.

Worked example: order #4521 stops being a memory and becomes a live piece of data

Go back to Module 3's mini-project. There, the customer at phone number +1-555-8811-2299 asked about their order #4521, and the agent responded correctly — but relying on the get_order_status tool with made-up data first, and then on what Postgres memory remembered had been said yesterday. Neither one is TuTienda's real order system. Today you connect that missing piece.

Step 1 — add the real operations database to your docker-compose.yml. You already have Module 3's postgres service, dedicated exclusively to chat history. Add a second, separate Postgres service that represents the store's real order system — never mix the agent's memory database with the business database, even though both are Postgres and run on your same laptop:

# docker-compose.yml — the same one from Module 3, with store_db added
services:
  n8n:
    # ...same as Module 3...
    depends_on:
      - postgres
      - store_db

  postgres:
    # ...Module 3's chat memory service, unchanged...

  store_db:
    image: postgres:16
    restart: unless-stopped
    environment:
      - POSTGRES_USER=tutienda_app
      - POSTGRES_PASSWORD=${STORE_DB_PASSWORD}
      - POSTGRES_DB=tutienda_store
    volumes:
      - store_db_data:/var/lib/postgresql/data

volumes:
  n8n_data:
  postgres_data:
  store_db_data:
# .env — add this line next to the ones you already had
STORE_DB_PASSWORD=put_another_password_of_your_own_here
docker compose up -d

Step 2 — create the table and seed it with data that reflects today, not Module 3's snapshot. This simulates TuTienda already having this system running in production — you're just connecting to it:

docker compose exec store_db psql -U tutienda_app -d tutienda_store -c "
CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_phone TEXT NOT NULL,
  status TEXT NOT NULL,
  eta DATE
);
INSERT INTO orders (order_id, customer_phone, status, eta) VALUES
  (4521, '+1-555-8811-2299', 'delivered', '2026-07-20'),
  (4522, '+1-555-8811-2299', 'in transit', '2026-07-24');
"

Notice order #4521: in Module 3's persistent memory it was still listed as "in transit." In the real system, it was already delivered yesterday. That gap is exactly what an agent that only checks memory never catches.

Step 3 — create the Postgres credential for this database, separate from Module 3's. Credentials → New → Postgres:

Host      = store_db          # the service name, not "localhost"
Database  = tutienda_store
User      = tutienda_app
Password  = the one you set in .env
Port      = 5432
SSL       = Disable            # Docker's private network

Step 4 — connect the Postgres node to the agent's ai_tool port, with a parameterized query. This is the point most often overlooked: never concatenate the value from $fromAI() directly into the query text. n8n gives you a separate field, Query Parameters, to pass those values safely using positional markers ($1, $2):

# ai_tool CONNECTION -> node: Postgres
credential            = the store_db credential from Step 3
operation             = "Execute Query"
query                 = "SELECT status, eta FROM orders
                         WHERE order_id = $1 AND customer_phone = $2"
options.queryParameters = "={{ [
                             $fromAI('order_id', 'Order number the
                               customer mentioned, digits only', 'number'),
                             $('Chat Trigger').item.json.customerPhone
                           ] }}"
description            = "Use this tool ALWAYS when the customer asks
                          about an order's status — even if you already
                          discussed that order earlier in the
                          conversation. The status may have changed
                          since then. It needs the order number."

Notice something on purpose: order_id comes from $fromAI() — it's a piece of data the customer mentions in the chat, and it makes sense for the model to extract it from the message. But customer_phone doesn't come from $fromAI(), it comes from a fixed expression that reads the conversation's real phone number (the same customerPhone field you send in the curl body, same as in Module 3). If you let the model also decide the phone number, any text from the customer ("check the order for phone +1-555-0000-1111") could get the agent to look up someone else's orders. You're going to dig into this criterion — which field you do trust to the model and which you don't — in the next lesson; for now, hold onto the practical rule: identity of whoever's asking, fixed; data the customer provides in their message, $fromAI().

Step 5 — put the agent to the test with Module 3's same question. Activate the workflow and use the Chat Trigger node's production Chat URL:

curl -X POST "<your Chat URL, Production tab>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-postgres-tool",
    "customerPhone": "+1-555-8811-2299",
    "chatInput": "Where is my order #4521?"
  }'

What to expect:

{ "output": "Your order #4521 was already delivered, on July 20. If it never arrived or there's a problem with what you received, let me know and we'll look into it." }

Compare it to what that same agent, relying only on memory, answered in Module 3 to the same question a day later: it kept saying "in transit," because it was repeating the last thing that had been said, not what the real system showed at that moment. Open this turn's execution panel in n8n and confirm with your own eyes that the Postgres node ran — you're going to see the input it received (order_id: 4521, the phone number) and the exact result the database returned, not an assumption from the model.

Escalating to a human: Google Sheets and Gmail working together

There are questions the agent shouldn't try to resolve on its own — when the customer explicitly asks to talk to a person, or demands a refund. There you need two different real systems, working as two independent tools the agent can call within the same conversation: a Google Sheets spreadsheet as a queryable record, and Gmail as an immediate alert to the team.

Google credentials. Unlike Postgres, which runs on your own Docker, Gmail and Google Sheets are real Google services — you need an account and, on a self-hosted instance like yours, your own OAuth2 app in Google Cloud Console (unlike n8n Cloud, which ships a managed flow with no such step). Under Credentials → New → Gmail OAuth2 (or Google Sheets OAuth2), n8n shows you the redirect URL you need to add to your app's "Authorized redirect URIs" in Google Cloud; from there you copy the Client ID and Client Secret back into n8n's credential form. It's an infrastructure step you do once — the official guide in Resources covers the full detail.

Tool 1 — log the case in Google Sheets. Create a spreadsheet called, for example, "TuTienda Escalations" with a "Cases" sheet, and connect the Google Sheets node to the ai_tool port:

# ai_tool CONNECTION -> node: Google Sheets
credential          = your Google Sheets OAuth2 credential
document            = "TuTienda Escalations"
sheet                = "Cases"
operation            = "Append Row"
columns.timestamp        = "={{ $now }}"
columns.customer_phone   = "={{ $('Chat Trigger').item.json.customerPhone }}"
columns.order_id         = "={{ $fromAI('order_id', 'Order number
                              related to the escalation, if the
                              customer mentioned it', 'string', 'no data') }}"
columns.reason            = "={{ $fromAI('reason', 'Reason for the
                              escalation in one sentence, in the
                              customer's own words', 'string') }}"
description               = "Use this tool to leave a permanent record
                              every time a case gets escalated to human
                              support — a refund, a serious complaint, or
                              the customer explicitly asking to talk to
                              a person. Use it together with the tool
                              that alerts by email: this one leaves the
                              record, that one alerts immediately."

Tool 2 — alert the team by Gmail. Connect the Gmail node, resource "Message", operation "Send a message":

# ai_tool CONNECTION -> node: Gmail
credential      = your Gmail OAuth2 credential
resource        = "Message"
operation       = "Send a message"
to              = "escalations@tutienda.example"    # fixed — never $fromAI
subject         = "={{ 'Customer escalation — order #' +
                       $fromAI('order_id', 'Related order number',
                                'string', 'no data') }}"
emailType       = "Text"
message         = "={{ $fromAI('summary', '2-to-3-sentence summary of
                       why the customer asked to talk to a person or a
                       refund, including the order number if
                       mentioned', 'string') }}"
description      = "Use this tool to immediately alert the human
                     support team in the same cases where you use the
                     spreadsheet logging tool — never one without the
                     other."

Notice the to field again: it's a fixed value, written by you when configuring the node, not something the model decides with $fromAI(). If you left it open to the model's decision, any instruction inside the customer's message — "send a copy to my personal email" — could get the agent to forward the case summary to a recipient you never authorized. An internal alert's recipient isn't a piece of data the customer should be able to change with what they write.

What to expect. Write, from the same test chat:

curl -X POST "<your Chat URL, Production tab>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-escalation",
    "customerPhone": "+1-555-8811-2299",
    "chatInput": "I am done waiting, it has been weeks for order #4521 and nobody solves it. I want to talk to a person or get my money back."
  }'
{ "output": "I understand the frustration. Checking the system, order #4521 shows as delivered on July 20 — if it never arrived, it's important someone from our team confirm that with you directly. I've logged your case and alerted the support team; they'll reach out shortly." }

Unlike the Postgres tool, here you can't verify the result with a docker compose exec — Sheets and Gmail live outside your Docker. The real verification is two things you can check with your own eyes: the new row in the Google Sheets spreadsheet, and the email that actually arrives in the escalations@tutienda.example inbox. n8n's execution panel, meanwhile, shows you the exact input each tool received — useful for confirming what the model understood before going to check the real systems.

HTTP Request: the universal tool for any API

Gmail, Google Sheets, and Postgres cover common systems, but no n8n provider has a dedicated node for every API your business uses — the exchange-rate service, the internal billing system, the shipping provider's API. For those cases, the HTTP Request node works just like the previous ones: connect it to the ai_tool port and it becomes one more tool, capable of calling any endpoint.

Scenario: a customer paid in dollars and asks how much their refund is worth in their own currency. You're going to use Frankfurter's free public API (European Central Bank exchange rates, no account or API key needed) to solve this. First, confirm the API responds as expected, outside n8n:

curl "https://api.frankfurter.dev/v1/latest?base=USD&symbols=MXN"

What to expect:

{"amount":1.0,"base":"USD","date":"2026-07-21","rates":{"MXN":17.3943}}

Now connect the HTTP Request node to the agent:

# ai_tool CONNECTION -> node: HTTP Request
method           = "GET"
url              = "https://api.frankfurter.dev/v1/latest"
authentication    = "None"
queryParameters:
  base    = "USD"
  symbols = "={{ $fromAI('target_currency', '3-letter ISO currency code
              the customer wants to convert the amount to — for
              example MXN, EUR, or COP', 'string', 'MXN') }}"
description       = "Use this tool when the customer asks how much a
                      dollar amount is worth in their own currency, for
                      example to calculate a refund. It needs the
                      3-letter ISO currency code. Do not use it for
                      questions about an order's status — there's
                      another tool for that."

What to expect:

curl -X POST "<your Chat URL, Production tab>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-fx",
    "customerPhone": "+1-555-8811-2299",
    "chatInput": "I paid 49.99 dollars and I am getting a refund. How much is that in Mexican pesos today?"
  }'
{ "output": "At today's exchange rate (1 USD ≈ 17.39 MXN), 49.99 USD is about 869 MXN. The exact refund amount may vary slightly depending on the exchange rate on the day it's processed." }

Authentication stays at None because this specific API doesn't require it. For an API that does require a key, the same HTTP Request node supports predefined credentials (when n8n already ships support for that provider) or generic authentication — Basic, Header, Query, OAuth2, among others — configurable without leaving the node.

Common mistakes

Confusing the memory database with the real system's database (conceptual). What happens: someone connects the same Postgres used by Postgres Chat Memory (Module 3's) as a data source for a business tool, or the reverse, expects this lesson's Postgres tool to give the agent memory of the conversation. Why it happens: both pieces use the same database engine and can even live on the same physical server, but they play completely different roles — Postgres Chat Memory stores what was said, indexed by session; the Postgres node connected as a tool queries what's true right now in your business tables, with no relation to the chat history. How to spot it: ask yourself, for any query you run, whether the answer should change even though the conversation is exactly the same — if the answer can change without what the customer said changing (like an order's status), it's real-system data, not memory. How to fix it: keep them in separate databases, like in this lesson (postgres for memory, store_db for the real system), or at least in clearly different tables if they share a server.

Concatenating the value from $fromAI() directly into the SQL query text (practical). What happens: you write something like query = "SELECT status FROM orders WHERE order_id = " + $fromAI('order_id', ..., 'number') instead of using $1 and the Query Parameters field. It works in normal testing, but a model can, faced with a customer message designed to confuse it, end up producing a value that isn't an order number but a fragment of SQL — the classic SQL injection risk, now with the model as the intermediary instead of a web form. Why it happens: any text inserted directly into a query, with no sanitization mechanism, gets interpreted as part of the SQL — it doesn't matter whether that text was typed by a user into a form or generated by a language model. How to spot it: check every tool that uses Execute Query and look for signs of text concatenation inside the query field instead of $1, $2 markers with their values in Query Parameters. How to fix it: always use positional markers in the query and pass the values — whether they come from $fromAI() or a fixed expression — through the Query Parameters field, like in this lesson's example.

Leaving a field that identifies the recipient or the data's owner on $fromAI() (practical). What happens: an email's to field, or a database query's customer_phone, ends up configured with $fromAI() instead of a fixed value or an expression over verified conversation data. The customer, with no need for bad intent — or with it — can write something that changes that value: "send it to this other email" or "check the order for this other phone number." Why it happens: $fromAI() gets its value from what the model interprets from text anyone can write in the chat — it's exactly the right mechanism for data the customer provides about their own case (an order number, an amount), but not for data that defines who receives something or whose information gets queried. How to spot it: for every field with $fromAI() on a tool, ask yourself whether that value should be able to change based on whatever anyone writes in the chat — if the answer is no, it shouldn't be there. How to fix it: fix those fields with a literal value (like to in the Gmail example) or with an expression that reads already-verified conversation data (like customer_phone read from the Chat Trigger, not from the message text).

Exercises

Exercise 1 — Find the risk. A colleague connected this Postgres tool to look up a customer's email from their account number:

query = "SELECT email FROM customers WHERE account_id = " + $fromAI('account_id', 'Customer's account number', 'string')

It passes every normal test. What's missing, and how would you fix it?

See solution

It's missing use of the Query Parameters field with a positional marker instead of concatenating the value directly into the query text. The fixed version:

query = "SELECT email FROM customers WHERE account_id = $1"
options.queryParameters = "={{ [ $fromAI('account_id', 'Customer's account number', 'string') ] }}"

Why it works: with Query Parameters, n8n treats the value coming from $fromAI() as data — never as part of the SQL text to execute — no matter what characters it contains. With direct concatenation, any content in that value gets interpreted literally as part of the SQL instruction, opening the door for a customer message, processed by the model, to end up altering the query that gets executed.

Exercise 2 — Design the correct fields. You're going to add a fourth tool: when the customer asks to cancel an order, the agent should update (Update) that order's row in orders, changing status to 'cancelled'. Which fields on that tool would you leave in $fromAI() and which would you fix with an expression over conversation data? Justify each one.

See solution

order_id — in $fromAI(). It's a piece of data the customer provides about their own case when asking for the cancellation; the model should extract it from the message.

customer_phone (for the WHERE condition, not for what's updated) — fixed, with {{ $('Chat Trigger').item.json.customerPhone }}. Same as in the lesson's example: the phone number identifies who's asking and whose order it is, not something the customer should be able to change by writing different text.

status (the new value, 'cancelled') — fixed, literal, not $fromAI(). There's no point in the model "deciding" what value to change the status to in a cancellation action — the tool itself already represents that one action; if tomorrow you need another status (say, "paused"), that would be a different tool with its own description, not a field open for the model to write any status value it likes.

Why it works: it's the same criterion from this lesson — $fromAI() for data the customer provides about their own case; fixed or from already-verified data for everything that defines the action's scope (who it affects) or its exact effect (what value it changes to).

Exercise 3 — Choose the right system. An HR team wants an internal agent that, when an employee requests time off by chat, does three things: (a) check how many vacation days they have left, data that lives in the payroll database; (b) log the request in a spreadsheet HR reviews every week; (c) immediately alert the employee's direct manager. What kind of node would you connect as a tool for each of the three actions?

See solution

(a) Postgres (or whatever database engine the payroll system uses), operation Execute Query or Select — the same pattern from this lesson's example with the orders table, applied to a vacation-balances table.

(b) Google Sheets, operation Append Row — the same pattern as the escalation tool: a queryable record, not an ephemeral notification.

(c) Gmail (or whatever internal messaging system the company uses), Send a message — the same immediate-alert pattern to the support team, now applied to the direct manager.

Why it works: the criterion doesn't change between TuTienda and HR — it depends on what each action needs, not on the business domain. Data that lives in a table and changes over time → database. Queryable historical record → Sheets. Immediate alert to a person → email (or the equivalent messaging channel).

Exercise 4 — Adapt the exchange-rate tool. You want this lesson's Frankfurter tool to also handle refund calculations for customers in Colombia, in Colombian pesos. Do you need to build a new tool, or does the one you already built handle it? Explain why.

See solution

You don't need a new tool. The symbols field is already resolved with $fromAI('target_currency', ...), so the model can pass COP just as it would pass MXN — Frankfurter's API supports the Colombian peso's ISO code with no change from you. That's why, in the tool's description, you defined the purpose in general terms ("how much a dollar amount is worth in their own currency") instead of mentioning a specific currency.

Why it works: when you leave the value that varies (the target currency) as a $fromAI() parameter instead of fixing it inside the URL or the description, the same tool covers any case within that same pattern — you don't have to anticipate every country separately.

Summary and next step

Today you connected four types of real systems to the agent — Postgres, Google Sheets, Gmail, and generic HTTP Request — using the same mechanism in all four cases: the agent's ai_tool port and $fromAI() for the fields the model should fill in. And you resolved something left open since Module 3: an agent that used to just repeat what memory remembered now checks the real system before responding — the difference between order #4521 "in transit" (what was said yesterday) and "delivered" (what's true today).

Before moving on you should be able to: connect any compatible node to an agent's ai_tool port and write its description; decide, for any given field on a tool, whether it should go in $fromAI() or be fixed, and explain why; and use Query Parameters instead of text concatenation in any tool that runs SQL.

What you didn't do yet, on purpose: none of today's four tools asked a human for confirmation before executing, and no description made explicit what happens if the model gets it wrong about when to call it. Connecting the real system is only half the job — the other half is deciding, with judgment, how much you trust the model to use it well. That's exactly the next lesson.

Resources

  • How tools work — n8n Docs — overview of the available tool types and how the agent decides which to use.
  • Use AI for parameters ($fromAI) — n8n Docs — complete reference for the $fromAI() function, its four parameters, and its limits.
  • Postgres node — n8n Docs — available operations, including the section on Query Parameters to avoid SQL injection.
  • Gmail node — n8n Docs — message, draft, label, and thread operations, and its use as an agent's tool.
  • Google Sheets node — n8n Docs — document and sheet operations, including Append Row.
  • HTTP Request node — n8n Docs — authentication, query parameters, and body configuration for calling any API as a tool.