Day 15 of 30 Days of Search.
A university research group wants to develop low-cost sensors for drinking-water contamination. It needs funding for prototype development, lab validation and a community pilot. A search for “water research grants” returns opportunities, but the research office still has to work out which accept a university lead, which cover the proposed work and which are still open.
That investigation repeats every week. The project stays broadly the same; funding calls and deadlines change.
We will build a grant-matching agent with an optimized workflow that researches opportunities against a project profile and produces a cited shortlist with eligibility questions and next actions. We will start with the TypeScript code, create its workflow in the Valyu platform, then connect the two. Finally, we will schedule daily runs and show the manual weekly route.
TL;DR
- Save the research instructions once. A Valyu workflow holds the grant-search prompt, eligibility checks and report structure. Its inputs describe the project and applicant.
- Start with a small TypeScript runner. It previews the saved workflow, starts a DeepResearch task when requested, waits for completion and saves the report.
- Use one workflow from both places. Call it from code on a schedule, or fill in the same inputs and run it manually in the platform each week.
- Keep funding fit reviewable. Each candidate needs a named call, current status, official evidence, eligibility conditions and a next action. A topical match alone is insufficient.
- Refresh the date on every run. Pinning workflow version 1 preserves its instructions; the research and sources can change.
- The example uses Node.js 24+,
valyu-js2.10.5 and a Valyu API key.
What is a grant-matching workflow?
A grant-matching workflow researches funding opportunities against an applicant's project, location, eligibility and funding requirements, then returns a shortlist with evidence. Its output helps a research office decide which opportunities deserve an application discussion.
In this build, Valyu's DeepResearch engine handles discovery and research. The saved workflow defines the assignment. TypeScript supplies the changing inputs and stores the result. A scheduler decides when to invoke the code.
The schedule belongs to the job runner. The research instructions belong to the saved Valyu workflow.
Our sample profile is illustrative, not a representation of a particular university or researcher:
| Requirement | Example |
|---|---|
| Project | Low-cost drinking-water contamination sensors, including laboratory validation and a community pilot |
| Applicant | UK university group; proposed lead is a lecturer with three years of independent research experience |
| Partnerships | No confirmed commercial lead or industry partner |
| Funding | GBP 100,000 to GBP 500,000 over 12 to 24 months |
| Search window | Open or officially announced upcoming calls with deadlines in the next 90 days; rolling schemes allowed |
| Useful result | Up to five candidates with sources, conditions and next actions, or an explanation that no suitable match was verified |
Step 1: Set up the TypeScript project
Install Node.js 24 or newer, then create a folder:
mkdir grant-matching-workflow cd grant-matching-workflow npm init -y npm pkg set type=module npm install --save-exact valyu-js@2.10.5 Node can execute the erasable TypeScript used here directly. We do not need a separate build step to run this example. See Node's TypeScript support for the supported syntax.
Create .gitignore:
node_modules/ .env* results/ Get an API key from the Valyu platform and put it in a local .env file:
VALYU_API_KEY=your_valyu_api_key The key must belong to the organisation where you create the workflow. An otherwise valid key for another organisation cannot see that organisation's private workflow. Keep the key out of the article, screenshots and version control.
Step 2: Describe your project in a configuration file
Create project.json:
{ "project": "Develop and field-test low-cost drinking-water sensors that detect contamination in community water systems. The project needs prototype development, laboratory validation and a small field pilot with community partners.", "applicant": "A UK university research group. The proposed lead is a lecturer with three years of independent research experience. No commercial lead or confirmed industry partner. The university's funder registration and internal approvals must be checked.", "geography": "United Kingdom; international collaborations are possible where a UK university can lead.", "funding_needs": "GBP 100,000 to GBP 500,000 over 12 to 24 months. Research and pilot funding, not equity investment, loans, student scholarships or travel-only awards. Find open calls and officially announced upcoming calls with a deadline in the next 90 days; rolling calls are acceptable." } The applicant description matters as much as the project description. “Water sensors” can match a call's subject while the call requires a business lead, an established consortium or a particular career stage.
We keep the run date out of this file. The runner calculates a fresh UTC date each time, so tomorrow's scheduled job does not reuse today's cut-off. The --date option is useful when recording a walkthrough; it does not turn live research into a historical snapshot.
Step 3: Write the workflow runner before creating the workflow
Create run-grants.ts:
import { mkdir, open, readFile, writeFile } from 'node:fs/promises'; import { resolve, join } from 'node:path'; import { parseArgs } from 'node:util'; import { pathToFileURL } from 'node:url'; import { Valyu } from 'valyu-js'; export const WORKFLOW = 'grant-matching-brief'; export const VERSION = 1; export type Profile = Record<'project' | 'applicant' | 'geography' | 'funding_needs', string>; export function parameters(profile: Profile, date = new Date().toISOString().slice(0, 10)) { for (const key of ['project', 'applicant', 'geography', 'funding_needs'] as const) { if (typeof profile[key] !== 'string' || !profile[key].trim()) throw new Error(`Missing profile field: ${key}`); } if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date)) || new Date(date).toISOString().slice(0, 10) !== date) throw new Error('Use a valid YYYY-MM-DD date.'); return { project: profile.project, applicant: profile.applicant, geography: profile.geography, funding_needs: profile.funding_needs, as_of_date: date }; } export async function runGrants(client: Valyu, profile: Profile, options: { execute?: boolean; date?: string; directory: string; resume?: string; }) { await mkdir(options.directory, { recursive: true }); const taskPath = join(options.directory, 'task.json'); const resultPath = join(options.directory, 'result.json'); let taskId = options.resume; if (!taskId) { const workflowParams = parameters(profile, options.date); const preview = await client.workflows.preview(WORKFLOW, { workflowParams, workflowVersion: VERSION }); if (!preview.success || preview.workflow?.slug !== WORKFLOW || preview.workflow.version !== VERSION || !preview.resolved?.input) { throw new Error('Workflow preview failed. Check the slug, version, inputs and API-key organisation.'); } await writeFile(join(options.directory, 'preview.json'), JSON.stringify(preview, null, 2)); if (!options.execute) return { preview }; // An existing receipt prevents a second submission from this results directory. const receipt = await open(taskPath, 'wx'); try { await receipt.writeFile(JSON.stringify({ status: 'submitting', workflow: WORKFLOW, version: VERSION, workflowParams })); await receipt.sync(); const task = await client.deepresearch.create({ workflowId: WORKFLOW, workflowVersion: VERSION, workflowParams, mode: 'standard' }); if (!task.success || !task.deepresearch_id) throw new Error('Task creation failed; inspect the platform before retrying.'); taskId = task.deepresearch_id; await receipt.truncate(0); await receipt.write(JSON.stringify({ taskId, workflow: task.workflow, workflowParams }, null, 2), 0, 'utf8'); await receipt.sync(); } finally { await receipt.close(); } } console.log(`Research task: ${taskId}`); console.log(`Resume: node run-grants.ts --resume ${taskId} --out ${JSON.stringify(options.directory)}`); const result = await client.deepresearch.wait(taskId, { pollInterval: 10_000, maxWaitTime: 30 * 60 * 1000 }); await writeFile(resultPath, JSON.stringify(result, null, 2)); if (!result.success || result.status !== 'completed' || typeof result.output !== 'string') { throw new Error(`Report not complete. Inspect task ${taskId}; resume polling rather than submit another run.`); } await writeFile(join(options.directory, 'report.md'), result.output); return { taskId, result }; } async function main() { const { values } = parseArgs({ options: { run: { type: 'boolean', default: false }, profile: { type: 'string', default: 'project.json' }, date: { type: 'string' }, out: { type: 'string' }, resume: { type: 'string' }, } }); if (!process.env.VALYU_API_KEY) throw new Error('Set VALYU_API_KEY for the organisation that owns the workflow.'); const directory = resolve(values.out ?? join('results', new Date().toISOString().replaceAll(':', '-'))); const profile = values.resume ? {} as Profile : JSON.parse(await readFile(values.profile!, 'utf8')) as Profile; const output = await runGrants(new Valyu(process.env.VALYU_API_KEY), profile, { execute: values.run, date: values.date, directory, resume: values.resume, }); console.log('result' in output ? `Saved ${join(directory, 'report.md')}` : `Preview saved in ${directory}. Add --run to start research.`); } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { main().catch(() => { console.error('Grant workflow failed. Inspect results/task.json and the platform task before retrying. Check the key, profile and workflow version.'); process.exitCode = 1; }); } There are three important API calls:
-
workflows.preview()resolves version 1 with the supplied inputs. Preview does not start research or spend research credits. -
deepresearch.create()starts the saved workflow as an asynchronous research task. -
deepresearch.wait()polls that task and returns its result.
The create request uses workflowId, workflowVersion and workflowParams. The research prompt stays in the saved workflow. Do not also pass a freeform query, input, researchStrategy or reportFormat; those fields are mutually exclusive with a workflow run.
The runner writes a task receipt before waiting. If the local process stops, use the recorded task ID with --resume. If a receipt only says submitting, check the platform task records before trying another submission: the request may have reached the service even if the client did not receive its ID. The local receipt prevents another invocation from submitting into the same results directory; it is not a distributed exactly-once guarantee.
Prepare the files now; run them after the next step. The workflow slug does not exist in your organisation until you create it.
Step 4: Create the grant-matching workflow in Valyu
Open Workflows in the Valyu platform and click New workflow.
Under Identity, enter:
| Field | Value |
|---|---|
| Title | Grant Matching Brief |
| Description | Find research funding opportunities, check eligibility and deadlines against a project profile, and return a cited shortlist with next actions. |
Check the generated slug: grant-matching-brief. It must match WORKFLOW in the TypeScript file. Your organisation owns this custom template; readers following the tutorial create their own copy.
The template has three fields. Paste these instructions into the corresponding fields.
Prompt
As of {as_of_date}, find research grants that could fund this project: {project}. Applicant profile: {applicant}. Location and collaboration constraints: {geography}. Funding requirements: {funding_needs}. Return up to five credible opportunities, not a target you must fill. Verify each call's current status, applicant eligibility, scope, award size, duration and deadline from official funder or application pages. Distinguish an open call from an officially announced upcoming call, a rolling scheme and an expired previous round. Keep uncertain eligibility visible; a topic match alone does not establish eligibility. Do not invent open calls, dates, award amounts or funding programmes. If no suitable opportunity is verified, say so and explain the gaps. Research strategy
Search official funder websites, programme pages, call documents and application portals. Use directories and news only to discover candidates, then check the official call. For each candidate, verify the named call and round, lead-applicant country and organisation rules, career-stage restrictions, required partners, project scope, amount and currency, eligible costs, duration, deadline including time zone if published, and current open/upcoming/rolling/closed status. Do not infer a new round from last year's dates. Distinguish an expression-of-interest deadline from the full application deadline. If an official page is inaccessible or facts conflict, record what could not be verified and mark the candidate needs verification. Deduplicate by funder, call and round, not by every page describing it. Prioritising official sources here is a research instruction, not a request-level domain allowlist. Report format instructions
Produce a concise Markdown grant-matching brief. Start with the run date, project profile and a short decision summary. Include a shortlist table with programme and round, funder, status, amount/currency, duration, deadline/time zone or rolling, fit classification (potential match, conditional match, not eligible, needs verification), decisive eligibility evidence, outstanding requirement and next action. Add a compact evidence section for every shortlisted opportunity: link the official call and eligibility/deadline sources, state why it fits and the exact unresolved conditions. Separate ruled-out candidates with reasons. Finish with actions for the research office and search coverage/gaps. Do not describe a conditional match as confirmed eligible. Cite factual claims, preserve source URLs and use unknown where the source does not establish a value. A shortlist helps decide what to investigate; do not imply that an application was submitted or funding was awarded. The project changes between runs. The instructions for investigating calls and explaining eligibility stay in the template.
The prompt requests up to five credible opportunities. Five is a maximum, not a requirement to pad the report with weak candidates. A useful result may be “no verified full match,” followed by the closest conditional routes and the conditions that need checking.
Step 5: Set the variables, output and research mode
The platform detects the {variable} placeholders. Configure the five rows as follows and keep them required:
| Key | Type | Purpose |
|---|---|---|
as_of_date | date | The date used for the current investigation |
project | textarea | What the project will do and deliver |
applicant | textarea | Organisation type, career stage and known eligibility facts |
geography | text | Lead-applicant location and collaboration constraints |
funding_needs | textarea | Amount, duration, funding type and deadline window |
Use the exact keys from the code. funding_needs and fundingNeeds are different inputs.
Choose:
- Recommended mode: Standard.
- Output format: Markdown report.
- Deliverables: None for this build. The report itself contains the shortlist.
- Tools: Leave code execution, charts, screenshots and browser use off.
- Vertical: Consulting, used here to organise the template.
- Changelog:
Initial grant matching template with official call verification and conditional eligibility.
Click Create workflow. Confirm that the saved row shows Grant Matching Brief, grant-matching-brief, v1 and STANDARD.
Saving creates the reusable template. It does not start a funding search.
The “official sources first” strategy is an instruction to the research engine, not an enforced domain allowlist. If your application requires a hard source restriction, configure the run's search.includedSources using the DeepResearch search options. This example leaves discovery broad so it can find funders we have not named in advance.
Step 6: Preview the saved workflow from code
From the project folder, run:
node --env-file=.env run-grants.ts --out results/preview This saves results/preview/preview.json. Check that it contains:
{ "workflow": { "slug": "grant-matching-brief", "version": 1 } } The complete response also includes resolved.input, resolved.research_strategy, resolved.report_format, mode and tools. Open the file and confirm that your project, applicant and run date have replaced the placeholders.
If preview fails, check the organisation associated with the key, the slug, the version and the five input names. Fix that before starting research.
Step 7: Run the grant search and save the report
Start a research task:
node --env-file=.env run-grants.ts --run --out results/first-scan The runner prints the task ID and a resume command. When research completes, the directory contains:
results/first-scan/ preview.json task.json result.json report.md report.md is the readable briefing. result.json preserves the API response, and task.json records the task ID, workflow version and submitted inputs.
If waiting is interrupted, read the task ID from task.json and resume polling:
node --env-file=.env run-grants.ts \ --resume YOUR_EXISTING_TASK_ID \ --out results/first-scan This path polls the existing task rather than creating another one. A local polling timeout does not mean the remote investigation was cancelled.
What the live example returned
I ran the saved version 1 through this TypeScript runner with the illustrative profile and --date 2026-10-15. The task completed with a cited Markdown report. Its main conclusion was:
No opportunity is a verified clean match.
That is the research result for this run, not proof that no suitable funding exists. The report named conditional candidates, ruled out others and described gaps in the search.
Two findings show why the applicant profile is essential:
| Candidate from the run | Evidence checked for this article | Useful next action |
|---|---|---|
| EPSRC New Investigator Award: applicant-led mode | The official UKRI page lists a rolling scheme and eligibility conditions based on prior applications, funding history, supervision and career independence. A lecturer's job title alone does not settle eligibility. | Ask the research office to check the proposed lead's record and the scheme's aims before preparing an application. |
| Water Breakthrough Challenge 7, Catalyst Stream | The official compet...Source:https://dev.to/valyuai/how-to-build-a-grant-matching-ai-agent-for-your-research-project-1oki PreviousTikTok Engagement Rate in JavaScript: Why the Denominator Changes EverythingNextValidating unknown in TypeScript Without a Schema Library: Type Guards, Assertion Functions and a 40-Line Mini Validator Related posts![]() The Effort to Reach: Designing for Fitts’s Law0dev.to UX GitHub’s New Dependabot Verdict Can Lag Behind Your Next Commit0dev.to GitHub ![]() Document Review With Unsiloed and TypeSafe Jev0dev.to Machine Learning AmeriHealth Caritas Files 61-Worker WARN Notice in Orlando, Effective December 31, 20260dev.to Career Remote SWE Jobs Paying Up to $220k (Nov 2)0dev.to Career What are your goals for the week? #2030dev.to Career show = false, 2600)"
x-show="show" x-cloak x-transition.opacity.duration.200ms
class="fixed bottom-8 left-1/2 z-[80] -translate-x-1/2 rounded-lg border border-line bg-card px-4 py-2.5 text-sm text-heading shadow-card-hover"
role="status" aria-live="polite">
|




