id: contract-renewal-radar
namespace: company.team
description: |
Never miss a contract's notice deadline again: read each signed contract PDF
with an LLM, check the renewal terms in code, keep a registry of every
contract, and ask the owner to renew, renegotiate or cancel before the
contract renews automatically.
labels:
team: procurement
use-case: contract-renewals
variables:
claude_model: claude-sonnet-5-5
bucket: contracts
s3_endpoint: https://s3.us-east-1.amazonaws.com
region: us-east-1
filed_prefix: contracts/filed/
triggers:
- id: new_contract
type: io.kestra.plugin.aws.s3.Trigger
description: A signed contract PDF dropped into contracts/inbox/ is read and
filed, and moved to contracts/filed/. Shipped disabled; set the bucket
variables first.
interval: PT1M
disabled: true
endpointOverride: "{{ vars.s3_endpoint }}"
forcePathStyle: true
compatibilityMode: true
region: "{{ vars.region }}"
accessKeyId: "{{ secret('S3_ACCESS_KEY_ID') }}"
secretKeyId: "{{ secret('S3_SECRET_KEY_ID') }}"
bucket: "{{ vars.bucket }}"
prefix: contracts/inbox/
regexp: ".*\\.pdf$"
maxKeys: 1
action: MOVE
moveTo:
key: "{{ vars.filed_prefix }}"
- id: daily
type: io.kestra.plugin.core.trigger.Schedule
description: Check every contract's deadline each morning, ask for decisions and
roll renewed contracts forward.
cron: "0 8 * * *"
disabled: true
inputs:
mode: daily
inputs:
- id: mode
type: SELECT
displayName: Mode
description: ingest reads and files one contract; daily checks every deadline in
the registry.
values:
- ingest
- daily
defaults: ingest
- id: contract
type: FILE
displayName: Contract PDF (ingest only)
required: false
- id: owner
type: STRING
displayName: Contract owner (ingest only)
description: Name or Slack handle of the person who decides about this contract.
required: false
- id: decision_window_days
type: INT
displayName: Decision window (days)
description: Ask for a decision this many days before the notice deadline.
defaults: 45
- id: dry_run
type: BOOL
displayName: Dry run
description: Read, check and report only. Nothing is filed, no decision is
requested and the registry is not changed.
defaults: false
tasks:
- id: route
type: io.kestra.plugin.core.flow.Switch
value: "{{ inputs.mode }}"
cases:
ingest:
- id: require_contract
type: io.kestra.plugin.core.execution.Fail
condition: "{{ (inputs.contract ?? trigger.objects[0].uri ?? '') == '' }}"
errorMessage: Upload a contract PDF, or drop one into contracts/inbox/ with the
new_contract trigger enabled.
- id: read_contract
type: io.kestra.plugin.ai.completion.ChatCompletion
description: Read the renewal terms from the PDF. The answer is checked in code
before anything is filed.
provider:
type: io.kestra.plugin.ai.provider.Anthropic
apiKey: "{{ secret('ANTHROPIC_API_KEY') }}"
modelName: "{{ vars.claude_model }}"
configuration:
maxToken: 1500
retry:
type: exponential
interval: PT10S
maxInterval: PT2M
maxAttempts: 3
messages:
- type: SYSTEM
content: |
You read contracts for a procurement team. Extract the commercial and renewal terms of the attached contract exactly as written. Never guess: use null when the contract does not say.
Respond with one JSON object and nothing else:
{"vendor": "...", "title": "...", "annual_value": 0, "currency": "USD", "start_date": "YYYY-MM-DD", "end_date": "YYYY-MM-DD", "auto_renews": true, "renewal_term_months": 12, "notice_value": 60, "notice_unit": "days", "notice_method": "...", "notice_address": "...", "price_increase": "...", "renewal_clause": "...", "confidence": 0.9, "uncertainties": []}
- end_date: the last day of the current term.
- auto_renews: true if the contract continues or renews unless someone gives notice; false for a fixed term that simply ends.
- notice_value and notice_unit (days, weeks or months): how long before the end of the term notice must be given. null when the period is not stated as a number, for example "reasonable notice".
- notice_method and notice_address: how and where notice must be sent.
- renewal_clause: a verbatim quote of the renewal and notice wording, at most 400 characters.
- confidence: from 0 to 1, how sure you are about the dates and the notice period. List anything ambiguous in uncertainties.
- type: USER
contentBlocks:
- type: TEXT
text: Extract the renewal terms of this contract.
- type: PDF
uri: "{{ inputs.contract ?? trigger.objects[0].uri }}"
- id: check_terms
type: io.kestra.plugin.scripts.python.Script
dependencies:
- kestra
description: Parse the extraction and list what a person must confirm before the
contract is filed.
inputFiles:
answer.b64: "{{ outputs.read_contract.textOutput | base64encode }}"
outputFiles:
- terms.json
script: |
import base64, json, re
from datetime import date
from kestra import Kestra
text = base64.b64decode(open("answer.b64").read()).decode()
match = re.search(r"\{.*\}", text, re.S)
terms = json.loads(match.group(0)) if match else {}
issues = []
def day(value):
try:
return date.fromisoformat(str(value)[:10])
except ValueError:
return None
if not match:
issues.append("the model did not return the terms")
if not terms.get("vendor"):
issues.append("no vendor name")
if not day(terms.get("end_date")):
issues.append("no end date for the current term")
if terms.get("auto_renews") is None:
issues.append("unclear whether it renews automatically")
if terms.get("auto_renews") and not isinstance(terms.get("notice_value"), (int, float)):
issues.append("renews automatically but the notice period is not a number of days, weeks or months")
if terms.get("auto_renews") and not terms.get("renewal_term_months"):
issues.append("renews automatically but the renewal term is not stated")
if float(terms.get("confidence") or 0) < 0.75:
issues.append(f"low confidence ({terms.get('confidence')})")
issues += [f"model: {u}" for u in terms.get("uncertainties") or []]
json.dump(terms, open("terms.json", "w"))
Kestra.outputs({"needs_review": bool(issues), "issues": [re.sub(r"[{}%#`]", "", i)[:200] for i in issues],
"vendor": re.sub(r"[{}%#`]", "", str(terms.get("vendor") or "unknown vendor"))[:120]})
- id: confirm_terms
type: io.kestra.plugin.core.flow.If
description: A contract with missing or uncertain terms is filed only after a
person confirms or corrects them.
condition: "{{ not inputs.dry_run and outputs.check_terms.vars.needs_review }}"
then:
- id: ask_review
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: "📝 Contract from *{{ outputs.check_terms.vars.vendor }}* needs a
check before filing: {{ outputs.check_terms.vars.issues |
join('; ') }}. Confirm or correct: {{ kestra.url ?? ''
}}/ui/main/executions/{{ flow.namespace }}/{{ flow.id }}/{{
execution.id }}"
retry:
type: exponential
interval: PT10S
maxInterval: PT1M
maxAttempts: 3
- id: review
type: io.kestra.plugin.core.flow.Pause
description: Correct what the model could not read. Empty fields keep the
extracted value. Without an answer within seven days the run is
cancelled and nothing is filed.
pauseDuration: P7D
behavior: CANCEL
onResume:
- id: verdict
type: SELECT
displayName: Decision
values:
- File it
- Do not track this contract
defaults: File it
- id: end_date
type: DATE
displayName: Correct end of the current term
required: false
- id: auto_renews
type: SELECT
displayName: Renews automatically?
values:
- As extracted
- "Yes"
- "No"
defaults: As extracted
- id: notice_days
type: INT
displayName: Correct notice period in days
required: false
- id: renewal_months
type: INT
displayName: Correct renewal term in months
required: false
- id: finalize
type: io.kestra.plugin.scripts.python.Script
dependencies:
- kestra
description: Apply the corrections, roll a term that already renewed forward,
and compute the notice deadline.
inputFiles:
terms.json: "{{ outputs.check_terms.outputFiles['terms.json'] }}"
contract.pdf: "{{ inputs.contract ?? trigger.objects[0].uri }}"
meta.b64: "{{ {'s3_key': trigger.objects[0].key ?? '', 'upload': inputs.contract
?? '', 'filed_prefix': vars.filed_prefix, 'owner': inputs.owner ??
'unassigned', 'review': outputs.review.onResume ?? {}} | toJson |
base64encode }}"
outputFiles:
- record.json
script: |
import base64, calendar, hashlib, json, re
from datetime import date, datetime, timedelta, timezone
from kestra import Kestra
terms = json.load(open("terms.json"))
meta = json.loads(base64.b64decode(open("meta.b64").read()))
fix = meta["review"] or {}
today = date.today()
# The trigger has already moved the PDF from the inbox to the filed prefix.
name = (meta["s3_key"] or meta["upload"]).rsplit("/", 1)[-1]
source = meta["filed_prefix"] + name if meta["s3_key"] else f"uploaded file {name}"
def clean(value, limit=300):
return re.sub(r"[{}%#`]", "", str(value)).strip()[:limit] if value not in (None, "") else None
def day(value):
try:
return date.fromisoformat(str(value)[:10])
except ValueError:
return None
def add_months(d, months):
y, m = divmod(d.month - 1 + months, 12)
return date(d.year + y, m + 1, min(d.day, calendar.monthrange(d.year + y, m + 1)[1]))
unit_days = {"day": 1, "days": 1, "week": 7, "weeks": 7, "month": 30, "months": 30}
notice = terms.get("notice_value")
notice_days = round(notice * unit_days.get(str(terms.get("notice_unit")).lower(), 1)) if isinstance(notice, (int, float)) else None
end = day(fix.get("end_date")) or day(terms.get("end_date"))
auto = {"Yes": True, "No": False}.get(fix.get("auto_renews"), terms.get("auto_renews"))
notice_days = fix.get("notice_days") or notice_days
months = fix.get("renewal_months") or terms.get("renewal_term_months") or (12 if auto else None)
notes = []
while auto and end and end < today:
notes.append(f"renewed automatically on {end}")
end = add_months(end, int(months))
deadline = (end - timedelta(days=int(notice_days or 0))) if end and auto else end
file_it = fix.get("verdict", "File it") == "File it" and end is not None and (not auto or notice_days is not None)
vendor = clean(terms.get("vendor"), 120) or "unknown vendor"
digest = hashlib.sha256(open("contract.pdf", "rb").read()).hexdigest()[:8]
key = "contract-" + (re.sub(r"[^a-z0-9]+", "-", vendor.lower()).strip("-")[:40] or "unknown") + "-" + digest
record = {
"key": key, "vendor": vendor, "title": clean(terms.get("title"), 160), "annual_value": terms.get("annual_value"),
"currency": clean(terms.get("currency"), 8), "start_date": str(day(terms.get("start_date")) or ""),
"end_date": str(end or ""), "auto_renews": bool(auto), "renewal_months": months, "notice_days": notice_days,
"notice_deadline": str(deadline or ""), "notice_method": clean(terms.get("notice_method")),
"notice_address": clean(terms.get("notice_address")), "price_increase": clean(terms.get("price_increase")),
"renewal_clause": clean(terms.get("renewal_clause"), 400), "owner": clean(meta["owner"], 80),
"source": clean(source, 300), "status": "active", "decision": None,
"filed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"history": notes + (["terms corrected by a reviewer"] if any(fix.get(k) for k in ("end_date", "notice_days", "renewal_months")) or fix.get("auto_renews") in ("Yes", "No") else []),
}
json.dump(record, open("record.json", "w"))
value = f"{record['currency'] or ''} {record['annual_value']:,}/yr".strip() if isinstance(record["annual_value"], (int, float)) else "value not stated"
if not end:
line = f"*{vendor}*: not filed, the end of the term is unknown."
elif auto and notice_days is None:
line = f"*{vendor}* ({record['title'] or 'contract'}, {value}) renews automatically on {end}, but the notice period is unknown, so there is no deadline yet."
elif auto:
line = (f"*{vendor}* ({record['title'] or 'contract'}, {value}) renews automatically on {end} for {months} months. "
f"Notice due *{deadline}* ({(deadline - today).days} days), {record['notice_method'] or 'method not stated'}: {record['notice_address'] or 'address not stated'}.")
else:
line = f"*{vendor}* ({record['title'] or 'contract'}, {value}) is a fixed term that ends on *{end}*; it does not renew."
Kestra.outputs({"file": file_it, "key": key, "summary": line, "notice_deadline": str(deadline or "")})
- id: file_contract
type: io.kestra.plugin.core.flow.If
description: A contract already on file keeps its registry entry, so re-dropping
a PDF never erases its decisions or history.
condition: "{{ not inputs.dry_run and outputs.finalize.vars.file and
kv(outputs.finalize.vars.key, errorOnMissing=false) is null }}"
then:
- id: save_contract
type: io.kestra.plugin.core.kv.Set
description: One registry entry per contract, keyed by vendor and a hash of the
PDF.
key: "{{ outputs.finalize.vars.key }}"
kvType: JSON
value: "{{ read(outputs.finalize.outputFiles['record.json']) }}"
- id: post_filed
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: "📄 {{ inputs.dry_run ? '[dry run, not filed] ' : (not
outputs.finalize.vars.file ? 'Not filed: ' : (outputs.save_contract
is defined ? 'Filed: ' : 'Already on file, not changed: ')) }}{{
outputs.finalize.vars.summary }}"
retry:
type: exponential
interval: PT10S
maxInterval: PT1M
maxAttempts: 3
daily:
- id: registry
type: io.kestra.plugin.core.kv.GetKeys
prefix: contract-
- id: plan
type: io.kestra.plugin.scripts.python.Script
dependencies:
- kestra
description: Roll renewed contracts forward, close ended ones, find the
deadlines inside the decision window and write the renewal calendar.
inputFiles:
registry.b64: "{% for key in outputs.registry.keys %}{{ kv(key) | toJson |
base64encode }}
{% endfor %}"
outputFiles:
- renewals.csv
- plan.json
script: |
import base64, calendar, csv, json
from datetime import date, timedelta
from kestra import Kestra
today, window = date.today(), int({{ inputs.decision_window_days }})
# The KV store drops null fields, so every record gets all fields back with defaults.
FIELDS = ("vendor", "title", "annual_value", "currency", "end_date", "auto_renews", "renewal_months", "notice_days", "notice_deadline",
"notice_method", "notice_address", "renewal_clause", "owner", "status", "decision")
records = [{**dict.fromkeys(FIELDS), "history": [], **json.loads(base64.b64decode(line))} for line in open("registry.b64").read().split() if line]
def add_months(d, months):
y, m = divmod(d.month - 1 + months, 12)
return date(d.year + y, m + 1, min(d.day, calendar.monthrange(d.year + y, m + 1)[1]))
changed, due, events = set(), [], []
for r in records:
if r["status"] == "ended":
continue
end = date.fromisoformat(r["end_date"])
if today > end:
if r["status"] == "cancelling" or not r["auto_renews"]:
r["status"] = "ended"; r["history"].append(f"ended on {end}")
events.append(f"ended: {r['vendor']} ({end})")
else:
while end < today:
end = add_months(end, int(r["renewal_months"] or 12))
events.append(f"renewed: {r['vendor']} until {end}" + (" without a decision" if r["status"] == "awaiting decision" else ""))
r["history"].append(f"renewed until {end}")
r.update(status="active", decision=None, end_date=str(end),
notice_deadline=str(end - timedelta(days=int(r["notice_days"] or 0))))
changed.add(r["key"])
continue
deadline = date.fromisoformat(r["notice_deadline"])
r["days_left"] = (deadline - today).days
if r["status"] in ("active", "awaiting decision") and r["days_left"] < 0:
r["status"] = "notice missed"; changed.add(r["key"])
events.append(f"notice deadline missed: {r['vendor']} renews on {end}")
elif r["status"] in ("active", "awaiting decision") and r["days_left"] <= window:
if r["status"] == "active":
r["status"] = "awaiting decision"; changed.add(r["key"])
due.append(r)
due.sort(key=lambda r: r["days_left"])
urgent = [r for r in due if r["days_left"] <= 7]
with open("renewals.csv", "w", newline="") as f:
w = csv.writer(f)
w.writerow(["vendor", "title", "status", "end_date", "auto_renews", "notice_deadline", "days_left", "annual_value", "currency", "owner", "notice_method", "notice_address"])
for r in sorted(records, key=lambda r: r.get("notice_deadline") or "9999"):
w.writerow([r["vendor"], r["title"], r["status"], r["end_date"], r["auto_renews"], r["notice_deadline"], r.get("days_left", ""),
r["annual_value"], r["currency"], r["owner"], r["notice_method"], r["notice_address"]])
json.dump({"records": records, "changed": sorted(changed)}, open("plan.json", "w"))
what = lambda r: f"{'notice due' if r['auto_renews'] else 'ends'} {r['notice_deadline']} ({r['days_left']} days)"
Kestra.outputs({
"contracts": len(records), "due": len(due), "urgent": len(urgent), "events": len(events),
"decision_options": [f"{r['key']} · {r['vendor']} · {r['title'] or 'contract'} · {what(r)}" for r in due],
"digest": f"📅 *Contract renewals*: {len(records)} contracts on file, {len(due)} {'needs' if len(due) == 1 else 'need'} a decision"
+ (f", *{len(urgent)} within 7 days*" if urgent else "") + "."
+ "".join(f"\n• {r['vendor']}: {what(r)}, owner {r['owner']}" for r in due)
+ "".join(f"\n• {e}" for e in events),
})
- id: post_digest
type: io.kestra.plugin.core.flow.If
description: Post only when something needs attention or changed, so quiet days
stay quiet.
condition: "{{ outputs.plan.vars.due > 0 or outputs.plan.vars.events > 0 }}"
then:
- id: digest
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: "{{ inputs.dry_run ? '[dry run] ' : '' }}{{
outputs.plan.vars.digest }}"
retry:
type: exponential
interval: PT10S
maxInterval: PT1M
maxAttempts: 3
- id: ask_decisions
type: io.kestra.plugin.core.flow.If
condition: "{{ not inputs.dry_run and outputs.plan.vars.due > 0 }}"
then:
- id: ask_owners
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: "🗳️ {{ outputs.plan.vars.due }} contract(s) need a renew,
renegotiate or cancel decision: {{ kestra.url ?? ''
}}/ui/main/executions/{{ flow.namespace }}/{{ flow.id }}/{{
execution.id }}"
retry:
type: exponential
interval: PT10S
maxInterval: PT1M
maxAttempts: 3
- id: decisions
type: io.kestra.plugin.core.flow.Pause
description: One form for every contract in its decision window. A contract left
unpicked stays awaiting a decision and is asked again tomorrow.
The form closes after 23 hours, before the next daily run.
pauseDuration: PT23H
behavior: RESUME
onResume:
- id: cancel
type: MULTISELECT
displayName: Cancel (a termination notice is drafted for you to send)
expression: "{{ outputs.plan.vars.decision_options }}"
required: false
- id: renegotiate
type: MULTISELECT
displayName: Renegotiate
expression: "{{ outputs.plan.vars.decision_options }}"
required: false
- id: renew
type: MULTISELECT
displayName: Renew as is
expression: "{{ outputs.plan.vars.decision_options }}"
required: false
- id: note
type: STRING
displayName: Note (kept with the decision)
required: false
- id: apply
type: io.kestra.plugin.core.flow.If
condition: "{{ not inputs.dry_run }}"
then:
- id: decide
type: io.kestra.plugin.scripts.python.Script
dependencies:
- kestra
description: Record the decisions, draft a termination notice for each
cancellation and list the registry entries to update.
inputFiles:
plan.json: "{{ outputs.plan.outputFiles['plan.json'] }}"
answers.b64: "{{ (outputs.decisions.onResume ?? {}) | toJson | base64encode }}"
outputFiles:
- notices/*.txt
script: |
import base64, json, os
from datetime import date, datetime, timezone
from kestra import Kestra
plan = json.load(open("plan.json"))
answers = json.loads(base64.b64decode(open("answers.b64").read()))
picked = {choice: {o.split(" · ")[0] for o in answers.get(field) or []}
for field, choice in (("cancel", "cancelling"), ("renegotiate", "renegotiating"), ("renew", "renewing"))}
note = str(answers.get("note") or "")[:500]
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
changed, decided, conflicts, waiting = set(plan["changed"]), [], [], []
os.makedirs("notices", exist_ok=True)
for r in plan["records"]:
choices = [c for c, keys in picked.items() if r["key"] in keys]
if len(choices) > 1:
conflicts.append(r["vendor"])
elif choices:
r.update(status=choices[0], decision={"choice": choices[0], "note": note, "decided_at": now, "execution": "{{ execution.id }}"})
r["history"].append(f"{choices[0]} decided on {now[:10]}")
changed.add(r["key"]); decided.append(f"{r['vendor']}: {choices[0]}")
if choices[0] == "cancelling" and r["auto_renews"]:
with open(f"notices/{r['key']}.txt", "w") as f:
f.write(f"To: {r['vendor']}\nSend by: {r['notice_method'] or 'see the contract'}\nAddress: {r['notice_address'] or 'see the contract'}\n"
f"Send before: {r['notice_deadline']}\n\nSubject: Notice of non-renewal: {r['title'] or 'our agreement'}\n\n"
f"Dear {r['vendor']},\n\nIn accordance with the renewal terms of our agreement"
f"{' (' + r['renewal_clause'] + ')' if r['renewal_clause'] else ''}, we give notice that we will not renew it. "
f"The agreement will end at the end of the current term on {r['end_date']}.\n\n"
"Please confirm receipt of this notice in writing.\n\nKind regards,\n[Name, title, company]\n")
if r["status"] == "awaiting decision" and r.get("days_left", 99) <= 7:
waiting.append(f"{r['vendor']} (notice due {r['notice_deadline']}, {r['days_left']} days)")
updates = [{"key": r["key"], "record_b64": base64.b64encode(json.dumps({k: v for k, v in r.items() if k != "days_left"}).encode()).decode()}
for r in plan["records"] if r["key"] in changed]
Kestra.outputs({"updates": updates, "update_count": len(updates), "decided": len(decided),
"notices": len([f for f in os.listdir("notices")]),
"result": ("✅ Decisions recorded: " + "; ".join(decided) + "." if decided else "")
+ (f" ⚠️ Picked in more than one list, not recorded: {', '.join(conflicts)}." if conflicts else "")
+ (f" 🚨 *Still no decision, renews automatically soon*: {'; '.join(waiting)}." if waiting else "")})
- id: save
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs.decide.vars.update_count > 0 }}"
then:
- id: update_registry
type: io.kestra.plugin.core.flow.Loop
description: Write every changed contract back to the registry.
values: "{{ outputs.decide.vars.updates }}"
concurrencyLimit: 4
tasks:
- id: save_entry
type: io.kestra.plugin.core.kv.Set
key: "{{ fromJson(item.value).key }}"
kvType: JSON
value: "{{ fromJson(item.value).record_b64 | base64decode }}"
- id: report
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs.decide.vars.result != '' }}"
then:
- id: post_result
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: "{{ outputs.decide.vars.result }}{{ outputs.decide.vars.notices > 0
? ' Termination notice drafts are attached to the execution:
' ~ (kestra.url ?? '') ~ '/ui/main/executions/' ~
flow.namespace ~ '/' ~ flow.id ~ '/' ~ execution.id : '' }}"
retry:
type: exponential
interval: PT10S
maxInterval: PT1M
maxAttempts: 3
errors:
- id: alert_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alert with the failed task, the error and a link. The registry is
only written at the end of a run, so a failed run can simply be run again.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
retry:
type: constant
interval: PT10S
maxAttempts: 3
messageText: |
❌ *Contract renewal radar FAILED* ({{ inputs.mode }}; flow {{ flow.namespace }}.{{ flow.id }}, execution {{ execution.id }}).
Failed task: {{ (errorLogs() | length) > 0 ? errorLogs()[0].taskId : 'unknown' }}. Error: {{ (errorLogs() | length) > 0 ? errorLogs()[0].message : 'see the execution logs' }}
{{ kestra.url ?? '' }}/ui/main/executions/{{ flow.namespace }}/{{ flow.id }}/{{ execution.id }}
outputs:
- id: filed_contract
type: STRING
displayName: Registry key of the filed contract
value: "{{ outputs.finalize.vars.key ?? 'none' }}"
- id: notice_deadline
type: STRING
displayName: Notice deadline of the filed contract
value: "{{ outputs.finalize.vars.notice_deadline ?? 'none' }}"
- id: due
type: INT
displayName: Contracts that need a decision
value: "{{ outputs.plan.vars.due ?? 0 }}"
- id: calendar
type: STRING
displayName: Renewal calendar (renewals.csv)
value: "{{ outputs.plan.outputFiles['renewals.csv'] ?? 'none' }}"