Book a free call

An AI agent that qualifies Polish public tenders

Baza Konkurencyjności publishes hundreds of tender notices a week and almost none of them fit any given supplier. This workflow drops the obvious noise locally and hands the rest to an agent that returns a GO or NO_GO decision along with the questions worth asking the buyer.

Author

Published:
Last verified:
n8n version:
2.8.4

In short

What it does

  • Pulls tender notices from Baza Konkurencyjności twice a day
  • Drops the obvious misses locally, before you pay for tokens
  • Qualifies the rest into GO, GO_IF or NO_GO
  • Returns the price weighting, risks and questions for the buyer
  • Reports to Telegram, skipping rejected notices

What it does not do

  • Does not measure its own accuracy — there is no evaluation set
  • Does not prune the list of seen announcements, which grows unbounded
  • Does not cover registers other than Baza Konkurencyjności
  • Does not decide for you — it qualifies, it does not bid

What it needs

  • n8n with LangChain nodes
  • An API key for a chat model
  • A Telegram bot and chat ID

An AI agent that qualifies Polish public tenders

Baza Konkurencyjności is a public register where beneficiaries of EU grants in Poland are legally required to publish their calls for tenders. It is a condition of settling the expense, so compliance is near-total. The result is an openly available list of companies that have just described what they are buying, for how much, by when, and who to contact about it.

Getting at the data is not the problem. The problem is that there are hundreds of notices a week and almost none of them fit any given supplier. This workflow reads the list twice a day, drops the obvious misses locally, and hands only what is left to a model that returns a decision in a fixed shape.

The corrected workflow canvas with every section frame wide enough to enclose its own nodes
The same workflow after regridding. The paragraph fits on three lines and the frame contains the section it describes.

Why the naive version breaks

The instinct is to fetch the list, throw everything at a model, and read the answer. Three things break.

Cost

One announcement with full context runs to roughly 12 thousand tokens. A hundred announcements per run, two runs a day, and you are paying to have a model read tenders for road works and catering. The model should only ever see what has a chance of mattering.

The answer reads well and processes badly

A model that replies in prose has to be parsed. Parsing prose out of an LLM is the shortest path to a silent failure: the phrasing shifts with the next model version, nothing throws, and nobody notices for a month.

The list entry omits the part that decides

This third point is also the answer to why there is an agent here at all rather than a single prompt. The decision depends precisely on fields the list does not carry. The agent judges for itself when a full record is worth fetching, instead of burning a request on every announcement. A prompt without a tool would have to either guess or fetch everything.

Configuration lives in one place

Every endpoint, keyword and criterion sits in a single Set node. Nothing is hardcoded into the code nodes or the HTTP nodes.

The list endpoint
curl "https://bazakonkurencyjnosci.funduszeeuropejskie.gov.pl/api/announcements/search?page=1&limit=100&sort=default&status%5B0%5D=PUBLISHED"

Two of those fields go straight into the agent's system prompt, and they are what determines the quality of the output.

companyProfile
Two-person software house. We build custom web applications, internal
business systems, production and booking software. Stack: Next.js, React,
TypeScript, Python/FastAPI, PostgreSQL, Docker. We do NOT do: embedded
firmware, hardware, construction work, hardware resale, off-the-shelf
ERP licences.
dealBreakers
Price carries more than 80% of the award criteria. The subject is
off-the-shelf software or licences rather than a custom build. The scope
is mostly outside our stack and partial offers are not allowed. Required
references or track record we do not have. Deposit or security above what
a small company can post.

The profile is a parameter, not prompt copy. Changing your stack or your appetite for risk is an edit to one field, not a rewrite of the instructions.

The local filter, before the model

This is the most important part of the whole thing and also the cheapest.

pre-filter.js
const strip = s => String(s ?? '').toLowerCase()
  .replace(/ł/g,'l').replace(/ą/g,'a').replace(/ę/g,'e').replace(/ś/g,'s')
  .replace(/ć/g,'c').replace(/ż/g,'z').replace(/ź/g,'z')
  .replace(/ó/g,'o').replace(/ń/g,'n');

const KEEP = String(cfg.keywords || '').split(',').map(s => strip(s.trim()));
const DROP = String(cfg.excludeKeywords || '').split(',').map(s => strip(s.trim()));

// Exclusions are checked BEFORE keywords — they win.
if (DROP.some(w => text.includes(w))) continue;
if (KEEP.length && !KEEP.some(w => text.includes(w))) continue;

Three decisions worth explaining.

Polish diacritics are stripped before comparison. Notices get written without accents, in all caps, with typos. After normalisation every variant collapses to one token.

Word fragments, not whole words. The list holds oprogramowan, not oprogramowanie. Polish inflection eats word endings, and matching on the stem catches every case without enumerating forms.

Exclusions take priority over keywords. This is the rule that makes the biggest difference, and it is worth justifying with real examples.

What to let through

Phrases that describe the service rather than the object:

oprogramowanie na zamowienie aplikacja webowa wdrozenie ERP system informatyczny

What to cut

Hardware and licences sold under software-sounding names:

system podawania palet system stabilizacji gruntu licencje na oprogramowanie dostawa sprzetu

:::

Public procurement is full of hardware bought under software-sounding names: a pallet feeding system, a soil stabilisation system, backup software licences. One generic word like system will drag every item in the right-hand column in front of the agent, and each one costs about 12 thousand tokens to be told no.

Two thresholds on top of that:

ParameterValueWhy
minDaysToDeadline5preparing a bid in three days is wasted effort
maxPerRun5a hard ceiling on the cost of one run

Deduplication, and what is wrong with it

dedupe.js
const store = $getWorkflowStaticData('global');
store.seen = store.seen || [];

if (!id || store.seen.includes(id)) continue;
store.seen.push(id);

The key is the numeric announcement id. A re-published procedure gets a new id, so it comes through as new. That is deliberate: a re-run usually means the first attempt failed to award, which is signal rather than noise.

The agent and its enforced structure

Close-up of the agent section: the agent node with a chat model, the tender_details HTTP tool and a structured output parser, all inside the section frame
Three sub-nodes attached to the agent: the model, the HTTP tool for announcement details, and the parser that enforces the response shape.

The agent runs with maxIterations, a hard cap on tool loops. The tender_details tool is an HTTP request tool with its output trimmed:

SettingValue
dataFielddata
fieldsToIncludeselected
fieldstitle, submission_deadline, partial_offer_allowed, orders, contact_persons

The tool description carries a cost instruction, not just a functional one:

Call it at most once per announcement, and only when the decision genuinely depends on the award criteria or the real scope.

The agent reads tool descriptions as part of its context. Putting the usage condition there works better than repeating it in the system prompt.

The output schema

output-schema.json
{
  "type": "object",
  "properties": {
    "verdict": { "type": "string", "enum": ["GO", "NO_GO", "GO_IF"] },
    "confidence": { "type": "number" },
    "one_line_reason": { "type": "string" },
    "price_weight_percent": { "type": ["number", "null"] },
    "estimated_value": { "type": ["string", "null"] },
    "contact": { "type": ["string", "null"] },
    "condition_to_verify": { "type": ["string", "null"] },
    "scope_in_our_wheelhouse": { "type": "string" },
    "scope_outside_our_wheelhouse": { "type": "string" },
    "open_questions": { "type": "array", "items": { "type": "string" } },
    "risks": { "type": "array", "items": { "type": "string" } }
  },
  "required": ["verdict", "one_line_reason",
               "scope_in_our_wheelhouse", "open_questions"]
}

Three things in that schema are deliberate.

The third value, GO_IF. A binary GO/NO_GO forces the model to fake a confidence it does not have. GO_IF together with condition_to_verify lets it express "worth it, provided X holds", which is the most common honest answer.

Nullable types on price_weight_percent, estimated_value and contact. Those fields are frequently absent from a notice. Without null in the schema the model invents a plausible value, because the schema demands a string.

open_questions among the required fields. This is the safety valve. The prompt says it outright:

Do not invent facts. If something is genuinely unknown after using the tool, say so in open_questions rather than guessing.

A required field for admitting ignorance gives the model somewhere to put the gap, instead of filling it inside a decision field.

The last line of the prompt is an instruction about asymmetric cost, not about accuracy:

Be decisive. A borderline GO that wastes two days of work is worse than an honest NO_GO.

The report

report.js
const worth = rows.filter(r => r && r.verdict && r.verdict !== 'NO_GO');
if (!worth.length) return [];

NO_GO verdicts stay in the workflow output but do not go to Telegram. This is not about hiding failures or saving space.

The point is that a negative verdict and a positive one have different lifecycles. A GO needs to be seen immediately, because the submission deadline is running. A NO_GO gets read once a week while tuning the exclusion list, and what matters there is the recurring pattern rather than the individual notice.

If everything went to the chat, nobody would be reading that chat within a week. A channel where nine messages in ten need no response stops being a notification channel.

The rejected verdicts are not empty, either. A typical NO_GO looks like this:

In scope for us

Design and delivery of software components: vision and analysis algorithms, web UI, backend, integration with the device API, module tests.

Out of scope for us

Mechanical and electrical installation, embedded firmware, on-site hardware validation, warranties and spare parts, construction work.

:::

Plus a condition_to_verify naming what would turn this same tender into a GO_IF, and a list of questions for the buyer. It is material for a decision — just not today's decision.

Message text is truncated to 3900 characters, because Telegram rejects anything longer.

Telegram report with a single GO_IF verdict, the price weighting at 80 percent, the condition to verify and four questions for the buyer
A GO_IF verdict names what would have to be true for the bid to make sense — here, a consortium or a written third-party commitment covering the financial and reference thresholds.

If the agent is rejecting things you would want to see, the fix is companyProfile or dealBreakers, not the prompt. The prompt reads those fields verbatim, which is why they are parameters.

Limitations

No measurement of accuracy. We did not maintain an evaluation set with human-assigned labels, so we do not know the rate of false GO verdicts or of missed opportunities. Anyone deploying this should score the verdicts by hand in parallel for the first month. Without that, a well-tuned company profile is indistinguishable from a model that agrees with everything.

No measurement of cost. The 12-thousand-token figure comes from the build phase, not from an invoice.

The unpruned seen-list, described above.

The local filter can cut valid announcements. Exclusions win, so a tender for a production system whose scope mentions hardware delivery is dropped before the model ever sees it. That is a deliberate trade against cost, but you should know you are making it.

The workflow qualifies, it does not decide. A false GO costs a day of work. A false NO_GO costs a contract. The verdict is an input to a conversation, not the end of one.

When this is not worth it

If you bid on a handful of tenders a year and read each one in full anyway, this workflow adds nothing. It starts to pay off at the point where scanning the list alone takes an hour a week and you reject more than 90% of notices on the title.

Take it with you

How to cite this

Igor Panek (2026). An AI agent that qualifies Polish public tenders. SEVENEDGE. https://sevenedge.pl/en/workflows/przetargi-baza-konkurencyjnosci-agent-ai (accessed: August 29, 2026)

Sources

  1. Baza KonkurencyjnościAPI docs
  2. n8n docs — AI Agentn8n docs
  3. n8n docs — Structured Output Parsern8n docs

Who is behind this

Igor Panek

Co-founder of SEVENEDGE

Co-founder of SEVENEDGE. Responsible for process automation: builds and maintains the self-hosted n8n instance running client deployments, and publishes workflows to the n8n template library. Outside automation, works on the same stack as the other co-founder — Next.js, FastAPI, PostgreSQL.

Related documents

Other workflows we documented the same way.

7SVENUsually replies right away
Hi! I'm SVEN from SEVENEDGE. We build apps, SaaS and AI automations. How can I help?
SVEN, the SEVENEDGE mascot, looking through a requestSVEN, the SEVENEDGE mascot, thinking over a coffeeSVEN, the SEVENEDGE mascot, working at a laptop
Got an app idea?