Render Tutorials
Stock research that survives a closed browser

Add Workflows in your IDE

⏱ 10 min

You felt the missing receipt. A Workflow runs tasks outside any open HTTP request and gives each run a stable ID. On this page you clone the fork, wrap the research steps as Workflow tasks (one root task plus several step tasks), and change the web API to return the root task-run ID as the receipt. All code edits for the tutorial happen here. Then you push once.

Do not edit src/research-stock.ts, public/app.js, or public/tracker.js. The mock steps stay in research-stock.ts. You only wrap those exports in workflows.ts and change how the web service starts research.

1. Clone your fork

You need Git and Node 20+ on this machine.

Terminal window
git clone https://github.com/YOUR_GITHUB_USERNAME/stock-research-agent-starter.git
cd stock-research-agent-starter
npm install

Open that folder in your IDE. Edit files locally for the rest of this page.

2. Register the tasks in src/workflows.ts

Replace the entire file (including the starter comments) with:

src/workflows.ts
/**
* workflows.ts: register research steps as Workflow tasks.
*
* Each plain function from research-stock.ts is wrapped with task(...).
* The root task (researchStock) calls those wrappers so Render chains
* child task runs (and can retry each step on its own).
*
* The web service starts ONLY the root: {slug}/researchStock
*/
import { task } from "@renderinc/sdk/workflows"
import {
loadCompanyFacts,
collectSignals,
identifyCatalysts,
identifyRisks,
writeMemo,
type ResearchMemo,
} from "./research-stock.js"
/** Step task: look up company name from the mock dataset. */
export const loadCompanyFactsTask = task(
{
name: "loadCompanyFacts",
timeoutSeconds: 120,
},
async function loadCompanyFactsTask(ticker: string) {
return loadCompanyFacts(ticker)
},
)
/** Step task: mock bullish / mixed signals. */
export const collectSignalsTask = task(
{
name: "collectSignals",
timeoutSeconds: 120,
},
async function collectSignalsTask(ticker: string) {
return collectSignals(ticker)
},
)
/** Step task: mock upcoming catalysts. */
export const identifyCatalystsTask = task(
{
name: "identifyCatalysts",
timeoutSeconds: 120,
},
async function identifyCatalystsTask(ticker: string) {
return identifyCatalysts(ticker)
},
)
/** Step task: mock key risks. */
export const identifyRisksTask = task(
{
name: "identifyRisks",
timeoutSeconds: 120,
},
async function identifyRisksTask(ticker: string) {
return identifyRisks(ticker)
},
)
/** Step task: combine step outputs into the memo. */
export const writeMemoTask = task(
{
name: "writeMemo",
timeoutSeconds: 120,
},
async function writeMemoTask(input: {
ticker: string
company: string
currentSignals: string[]
potentialCatalysts: string[]
keyRisks: string[]
}): Promise<ResearchMemo> {
return writeMemo(input)
},
)
/**
* Root task: what the web service starts.
*
* Call the *task wrappers* (not the plain research-stock functions) so each
* step becomes its own chained task run. Independent steps use Promise.all.
*/
export const researchStockTask = task(
{
name: "researchStock",
timeoutSeconds: 120,
},
async function researchStockTask(ticker: string): Promise<ResearchMemo> {
const facts = await loadCompanyFactsTask(ticker)
// Independent steps run together (parallel chained task runs).
const [currentSignals, potentialCatalysts, keyRisks] = await Promise.all([
collectSignalsTask(facts.ticker),
identifyCatalystsTask(facts.ticker),
identifyRisksTask(facts.ticker),
])
return writeMemoTask({
ticker: facts.ticker,
company: facts.company,
currentSignals,
potentialCatalysts,
keyRisks,
})
},
)
console.log(
"Registered Workflow tasks: loadCompanyFacts, collectSignals, identifyCatalysts, identifyRisks, writeMemo, researchStock",
)

Each task(...) registers one named function for a Workflow service. You wrap the plain exports from research-stock.ts. You do not rewrite that file.

The root task is researchStock. Inside it, call the task wrappers (loadCompanyFactsTask, collectSignalsTask, and so on), not only the plain functions. That chains child task runs under the root. Independent steps use Promise.all.

The starter already depends on @renderinc/sdk and has "workflow:start": "tsx src/workflows.ts".

3. Return a root ID from src/server.ts

Replace the entire file (including the starter comments). Keep the starter’s HTML/CSS/JS serving helpers. Change only how research starts: stop awaiting researchStock in-process, start the root Workflow task, and return that receipt. Include the status route so clients can poll:

src/server.ts
/**
* server.ts: web service after the Workflows tutorial step.
*
* Keeps the starter’s HTML/CSS/JS serving (inline CSS + /client.js bundle).
* Research no longer awaits in-process: POST starts the root Workflow task and
* returns a receipt (taskRunId). GET polls that root task run for status / memo.
*
* Env: WORKFLOW_SERVICE_SLUG = Dashboard Workflow Slug only (not slug/taskName).
* Env: RENDER_API_KEY = API key used by the Render SDK client.
*/
import express from "express"
import { readFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { Render } from "@renderinc/sdk"
const app = express()
const root = join(dirname(fileURLToPath(import.meta.url)), "..")
const publicDir = join(root, "public")
const render = new Render()
app.use(express.json())
app.use("/api", (_req, res, next) => {
res.set("Cache-Control", "no-store")
next()
})
app.get("/healthz", (_req, res) => {
res.status(200).json({ ok: true })
})
/** Inline CSS + point the page at the single /client.js bundle. */
function sendIndex(res: express.Response): void {
const css = readFileSync(join(publicDir, "styles.css"), "utf8")
let html = readFileSync(join(publicDir, "index.html"), "utf8")
html = html.replace(
/<link\s+rel="stylesheet"\s+href="\/styles\.css[^"]*"\s*\/?>/,
`<style>\n${css}\n</style>`,
)
html = html.replace(
/<script\s+src="\/app\.js[^"]*"\s+type="module"><\/script>/,
`<script src="/client.js" type="module"></script>`,
)
res.type("html").set("Cache-Control", "no-cache").send(html)
}
/** Concatenate tracker + app so the browser makes one JS request. */
function sendClientBundle(res: express.Response): void {
const tracker = readFileSync(join(publicDir, "tracker.js"), "utf8")
.replace(/\bexport\s+const\b/g, "const")
.replace(/\bexport\s+function\b/g, "function")
const appJs = readFileSync(join(publicDir, "app.js"), "utf8").replace(
/import\s*\{[^}]*\}\s*from\s*["']\.\/tracker\.js["']\s*;?\s*/,
"",
)
res
.type("js")
.set("Cache-Control", "no-cache")
.send(`${tracker}\n${appJs}`)
}
app.get("/", (_req, res) => sendIndex(res))
app.get("/index.html", (_req, res) => sendIndex(res))
app.get("/client.js", (_req, res) => sendClientBundle(res))
app.use(
express.static(publicDir, {
index: false,
setHeaders(res, filePath) {
if (filePath.endsWith(".js") || filePath.endsWith(".html") || filePath.endsWith(".css")) {
res.set("Cache-Control", "no-cache")
}
},
}),
)
/** Workflow Slug from the Dashboard (example: stock-research-agent-starter-1). */
function workflowSlug(): string {
const slug = process.env.WORKFLOW_SERVICE_SLUG?.trim()
if (!slug) {
throw new Error("WORKFLOW_SERVICE_SLUG is required")
}
return slug
}
/**
* Starts the root researchStock task and returns the task-run ID immediately.
* Do not start step tasks from here: the root chains them.
*/
app.post("/api/research", async (req, res) => {
const ticker = String(req.body?.ticker ?? "").trim()
if (!ticker) {
res.status(400).json({ error: "ticker is required" })
return
}
try {
const started = await render.workflows.startTask(
`${workflowSlug()}/researchStock`,
[ticker],
)
res.status(202).json({
taskRunId: started.taskRunId,
statusUrl: `/api/research/${started.taskRunId}`,
})
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to start research"
res.status(500).json({ error: message })
}
})
/**
* Returns root task-run status, and the memo when the root run completed.
* Clients poll this with the taskRunId from POST.
*/
app.get("/api/research/:taskRunId", async (req, res) => {
try {
const details = await render.workflows.getTaskRun(req.params.taskRunId)
if (details.status === "completed") {
res.json({
status: details.status,
startedAt: details.startedAt ?? null,
memo: details.results?.[0] ?? null,
})
return
}
if (details.status === "failed" || details.status === "canceled") {
res.status(500).json({
status: details.status,
startedAt: details.startedAt ?? null,
error: details.error ?? "Research failed",
})
return
}
res.json({
status: details.status,
startedAt: details.startedAt ?? null,
})
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load research"
res.status(500).json({ error: message })
}
})
const port = Number(process.env.PORT ?? "3000")
app.listen(port, "0.0.0.0", () => {
console.log(`stock-research listening on ${port}`)
})

What changed and why:

BeforeAfter
await researchStock(ticker) inside the requeststartTask(.../researchStock, [ticker]) for the root only
Response is the full memoResponse is { taskRunId } (HTTP 202) for the root run
Closing the tab leaves nothing to look upClient stores the root ID and polls GET /api/research/:taskRunId
Research steps run only inside the web processSeveral Workflow tasks registered; the root chains step tasks (including a Promise.all wave)

WORKFLOW_SERVICE_SLUG is the Dashboard Workflow Slug only. The code builds the full task identifier as `${slug}/researchStock`. Do not paste slug/researchStock into the env var.

Do not edit public/app.js or public/tracker.js. They already store the root ID, poll status, and resume the tracker.

4. Push to GitHub

Terminal window
git add src/workflows.ts src/server.ts
git commit -m "Register multi-task Workflows; web returns root task-run ID"
git push origin main

Confirm on GitHub that both files on main match what you just edited.

Which Workflow task does the web service start?

What you learned

  • Fork cloned; both files edited locally
  • Several tasks registered; API returns the root taskRunId
  • Next: create the Workflow service and wire env on the web service