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
- Igor PanekCo-founder of SEVENEDGE
- 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.

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.
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.
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.
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.
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:
| Parameter | Value | Why |
|---|---|---|
minDaysToDeadline | 5 | preparing a bid in three days is wasted effort |
maxPerRun | 5 | a hard ceiling on the cost of one run |
Deduplication, and what is wrong with it
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

The agent runs with maxIterations, a hard cap on tool loops. The tender_details tool is an HTTP request tool with its output trimmed:
| Setting | Value |
|---|---|
dataField | data |
fieldsToInclude | selected |
fields | title, 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
{
"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_questionsrather 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
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.

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
- Baza KonkurencyjnościAPI docs
- n8n docs — AI Agentn8n docs
- 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.
- Every agent needs to be told what it does not doGuides on multi-agent systems talk about assigning roles. We put the prompts from three of our workflows side by side and found something else in common: each one states what that agent must not do.
- What breaks when someone imports your n8n workflowWe submitted a workflow to the n8n template library and it came back rejected over one missing property in the JSON. Six months later the same file broke three more times on our own instance, for entirely different reasons.


