diff --git a/vm/vm.go b/vm/vm.go
index ba3b538..8d58f40 100644
--- a/vm/vm.go
+++ b/vm/vm.go
@@ -46,9 +46,10 @@ type VM struct {
 	debug        bool
 	step         chan struct{}
 	curr         chan int
-	scopePool    []Scope // Pre-allocated pool of Scope values; grows as needed but never shrinks
-	scopePoolIdx int     // Current index into scopePool for allocation
-	currScope    *Scope  // Cached pointer to the current scope (optimization)
+	scopePool    []Scope    // Pre-allocated pool of Scope values; grows as needed but never shrinks
+	scopePoolIdx int        // Current index into scopePool for allocation
+	currScope    *Scope     // Cached pointer to the current scope (optimization)
+	tries        []tryFrame // Active try/catch/finally frames
 }
 
 func (vm *VM) Run(program *Program, env any) (_ any, err error) {
@@ -81,6 +82,7 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) {
 	}
 	vm.scopePoolIdx = 0 // Reset pool index for reuse
 	vm.currScope = nil
+	vm.tries = vm.tries[:0]
 	if len(vm.Variables) < program.variables {
 		vm.Variables = make([]any, program.variables)
 	}
@@ -90,6 +92,41 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) {
 	vm.memory = 0
 	vm.ip = 0
 
+	// Execute the bytecode. A runtime error panics out of the loop, is
+	// recovered by exec, and is routed to the innermost active try frame
+	// (if any) by dispatch. Uncaught errors are re-panicked and converted
+	// into a file.Error by the deferred recover above.
+	for {
+		completed, caught := vm.exec(program, env)
+		if completed {
+			break
+		}
+		if !vm.dispatch(caught) {
+			panic(caught)
+		}
+	}
+
+	if len(vm.Stack) > 0 {
+		return vm.pop(), nil
+	}
+
+	return nil, nil
+}
+
+// exec runs the bytecode loop until it completes or panics. A panic is
+// recovered and returned as the caught value, so Run can route it to an
+// active try frame.
+func (vm *VM) exec(program *Program, env any) (completed bool, caught any) {
+	defer func() {
+		if r := recover(); r != nil {
+			caught = r
+		}
+	}()
+	vm.loop(program, env)
+	return true, nil
+}
+
+func (vm *VM) loop(program *Program, env any) {
 	var fnArgsBuf []any
 
 	for vm.ip < len(program.Bytecode) {
@@ -646,6 +683,37 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) {
 				vm.currScope = nil
 			}
 
+		case OpTry:
+			info := program.Constants[arg].(*TryInfo)
+			vm.tries = append(vm.tries, tryFrame{
+				info:         info,
+				stackLen:     len(vm.Stack),
+				scopesLen:    len(vm.Scopes),
+				scopePoolIdx: vm.scopePoolIdx,
+			})
+
+		case OpTryEnd:
+			f := &vm.tries[len(vm.tries)-1]
+			if f.info.FinallyAddr >= 0 {
+				f.state = tryStateFinally
+				f.pending = nil
+				vm.ip = f.info.FinallyAddr
+			} else {
+				vm.tries = vm.tries[:len(vm.tries)-1]
+				vm.ip = f.info.EndAddr
+			}
+
+		case OpFinallyEnd:
+			f := vm.tries[len(vm.tries)-1]
+			vm.tries = vm.tries[:len(vm.tries)-1]
+			if f.pending != nil {
+				panic(f.pending)
+			}
+			vm.ip = f.info.EndAddr
+
+		case OpRetry:
+			vm.retry()
+
 		default:
 			panic(fmt.Sprintf("unknown bytecode %#x", op))
 		}
@@ -659,12 +727,6 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) {
 		close(vm.curr)
 		close(vm.step)
 	}
-
-	if len(vm.Stack) > 0 {
-		return vm.pop(), nil
-	}
-
-	return nil, nil
 }
 
 func (vm *VM) push(value any) {
